From 826f3969606ebdd5e4d315dacf130408eb502a9b Mon Sep 17 00:00:00 2001 From: Luohua Date: Mon, 23 Mar 2026 17:05:37 +0800 Subject: [PATCH 1/2] Add autoplay port and fix runtime issues --- akagi_backend/akagi_ng/application.py | 135 +++-- akagi_backend/akagi_ng/autoplay/__init__.py | 3 + akagi_backend/akagi_ng/autoplay/executor.py | 348 ++++++++++++ akagi_backend/akagi_ng/autoplay/manager.py | 201 +++++++ akagi_backend/akagi_ng/autoplay/planner.py | 526 ++++++++++++++++++ .../akagi_ng/bridge/majsoul/bridge.py | 41 ++ akagi_backend/akagi_ng/core/lib_loader.py | 73 ++- akagi_backend/akagi_ng/schema/types.py | 2 + akagi_backend/akagi_ng/settings/settings.py | 66 +++ akagi_backend/scripts/build_backend.py | 36 +- .../tests/unit/test_autoplay_planner.py | 142 +++++ .../unit/test_bridge_majsoul_autoplay.py | 88 +++ .../src/components/SettingsPanel.tsx | 3 + .../components/settings/AutoplaySection.tsx | 168 ++++++ akagi_frontend/src/i18n/locales/en-US.json | 19 + akagi_frontend/src/i18n/locales/ja-JP.json | 21 +- akagi_frontend/src/i18n/locales/zh-CN.json | 21 +- akagi_frontend/src/i18n/locales/zh-TW.json | 21 +- akagi_frontend/src/types.ts | 14 + assets/settings.schema.json | 73 ++- electron/package.json | 3 +- 21 files changed, 1895 insertions(+), 109 deletions(-) create mode 100644 akagi_backend/akagi_ng/autoplay/__init__.py create mode 100644 akagi_backend/akagi_ng/autoplay/executor.py create mode 100644 akagi_backend/akagi_ng/autoplay/manager.py create mode 100644 akagi_backend/akagi_ng/autoplay/planner.py create mode 100644 akagi_backend/tests/unit/test_autoplay_planner.py create mode 100644 akagi_backend/tests/unit/test_bridge_majsoul_autoplay.py create mode 100644 akagi_frontend/src/components/settings/AutoplaySection.tsx diff --git a/akagi_backend/akagi_ng/application.py b/akagi_backend/akagi_ng/application.py index b062b55..6be5747 100644 --- a/akagi_backend/akagi_ng/application.py +++ b/akagi_backend/akagi_ng/application.py @@ -5,25 +5,16 @@ from types import FrameType from akagi_ng import AKAGI_VERSION -from akagi_ng.core.context import ( - AppContext, - get_app_context, - set_app_context, -) -from akagi_ng.core.logging import ( - configure_logging, - logger, -) +from akagi_ng.autoplay import AutoPlayManager, AutoPlayRuntime +from akagi_ng.core.context import AppContext, get_app_context, set_app_context +from akagi_ng.core.logging import configure_logging, logger from akagi_ng.dataserver import DataServer from akagi_ng.electron_client import create_electron_client from akagi_ng.mitm_client import MitmClient from akagi_ng.mjai_bot import Controller, StateTracker from akagi_ng.mjai_bot.status import BotStatusContext -from akagi_ng.schema.constants import ServerConstants -from akagi_ng.schema.protocols import ( - ControllerProtocol, - StateTrackerProtocol, -) +from akagi_ng.schema.constants import Platform, ServerConstants +from akagi_ng.schema.protocols import ControllerProtocol, StateTrackerProtocol from akagi_ng.schema.types import ( AkagiEvent, MJAIEventBase, @@ -43,6 +34,7 @@ def __init__(self): self._stop_event = threading.Event() self.ds: DataServer | None = None self.status: BotStatusContext | None = None + self.autoplay: AutoPlayManager | None = None self.frontend_url = "" self.message_queue: queue.Queue[AkagiEvent] = queue.Queue(maxsize=ServerConstants.MESSAGE_QUEUE_MAXSIZE) @@ -78,29 +70,20 @@ def initialize(self): electron_client=create_electron_client(settings.platform, shared_queue=self.message_queue), shared_queue=self.message_queue, ) - set_app_context(app_context) + self.autoplay = AutoPlayManager(runtime_provider=self._build_autoplay_runtime) def start(self): self.ds.start() logger.info(f"DataServer started at {self.frontend_url}") app = get_app_context() - - for source in filter( - None, - ( - app.mitm_client if app.settings.mitm.enabled else None, - app.electron_client, - ), - ): + for source in filter(None, (app.mitm_client if app.settings.mitm.enabled else None, app.electron_client)): source.start() self._setup_signals() def _setup_signals(self): - """设置信号处理器以关闭程序""" - def signal_handler(signum: int, _frame: FrameType | None): sig_name = signal.Signals(signum).name logger.info(f"Received signal {sig_name} ({signum}), initiating shutdown...") @@ -112,24 +95,63 @@ def signal_handler(signum: int, _frame: FrameType | None): def stop(self): self._stop_event.set() + def _get_active_bridge(self): + app = get_app_context() + if app.settings.mitm.enabled and app.mitm_client and app.mitm_client.addon: + addon = app.mitm_client.addon + if addon.activated_flows: + flow_id = addon.activated_flows[-1] + return addon.bridges.get(flow_id) + if addon.bridges: + return list(addon.bridges.values())[-1] + if app.electron_client: + return getattr(app.electron_client, "bridge", None) + return None + + def _detect_autoplay_platform(self) -> Platform: + app = get_app_context() + if app.settings.platform != Platform.AUTO: + return app.settings.platform + + bridge = self._get_active_bridge() + bridge_name = bridge.__class__.__name__.lower() if bridge else "" + if "tenhou" in bridge_name: + return Platform.TENHOU + if "riichi" in bridge_name: + return Platform.RIICHI_CITY + if "amatsuki" in bridge_name: + return Platform.AMATSUKI + return Platform.MAJSOUL + + def _get_latest_operation_list(self) -> list[dict]: + bridge = self._get_active_bridge() + operation_list = getattr(bridge, "latest_self_operation_list", []) + return list(operation_list) if isinstance(operation_list, list) else [] + + def _get_latest_operation_step(self) -> int | None: + bridge = self._get_active_bridge() + step = getattr(bridge, "latest_operation_step", None) + return int(step) if step is not None else None + + def _build_autoplay_runtime(self) -> AutoPlayRuntime: + app = get_app_context() + return AutoPlayRuntime( + platform=self._detect_autoplay_platform(), + window_keyword=app.settings.autoplay.window_keyword, + get_operation_list=self._get_latest_operation_list, + get_operation_step=self._get_latest_operation_step, + ) + def _handle_message( self, msg: AkagiEvent, tracker: StateTrackerProtocol | None, controller: ControllerProtocol | None ) -> tuple[str | None, bool, bool]: - """统一处理消息分发的 match-case 逻辑。 - - Returns: - (notification, handled, is_sync) - """ match msg: - # 1. 纯系统级别的管理事件 (不流向 Game Logic) case SystemShutdownEvent(): logger.info("Received shutdown signal.") self.stop() return None, True, False case SystemEvent(code=code): return code, True, False - - # 2. 属于 Game Logic / MJAI 范畴的协议事件 case MJAIEventBase(sync=is_sync): pass case _: @@ -139,107 +161,79 @@ def _handle_message( controller.react(msg) if tracker: tracker.react(msg) + if self.autoplay and isinstance(msg, MJAIEventBase): + self.autoplay.observe_event(msg) return None, False, is_sync def _process_event( self, msg: AkagiEvent, tracker: StateTrackerProtocol | None, controller: ControllerProtocol | None ) -> ProcessResult: - """ - 处理单条 MJAI 消息 - 这是 Reactor 模式的 PROCESS 阶段 - """ response: MJAIResponse | None = None notifications: list[Notification] = [] is_sync = False try: msg_code, handled, is_sync = self._handle_message(msg, tracker, controller) - - # 收集结果:决策响应(从 Controller 拉取) if controller and not handled: response = controller.last_response if msg_code: notifications.append(Notification(code=msg_code)) - # 每一条消息处理后,统一从 Context 中采集当前累积的标志 if not handled and self.status and self.status.flags: notifications.extend(Notification(code=code) for code in self.status.flags) self.status.clear_flags() - except Exception: logger.exception(f"Unexpected error processing MJAI message: {msg}") - return ProcessResult( - response=response, - notifications=notifications, - is_sync=is_sync, - ) + return ProcessResult(response=response, notifications=notifications, is_sync=is_sync) def _emit_outputs(self, result: ProcessResult, tracker: StateTrackerProtocol | None): - """ - 将处理结果发送到 DataServer - 这是 Reactor 模式的 OUTPUT 阶段 - """ if notifications := result.notifications: self.ds.send_notifications(notifications) - # 同步期间屏蔽推荐输出,仅保留通知发送。 - if result.is_sync: + if result.is_sync or tracker is None: return response = result.response or MJAIResponse(type="none") payload = tracker.build_recommendations(response) + if self.autoplay: + self.autoplay.execute(result.response, tracker) if payload: self.ds.send_recommendations(payload) def run(self) -> int: - """ - 使用 Reactor 模式的主应用循环。 - - 循环分三个阶段: - 1. message_queue.get() - 从事件队列收集消息 - 2. _process_event() - 处理消息并生成响应 - 3. _emit_outputs() - 发送结果到 DataServer - """ - # 启动主循环 logger.info("Starting main loop...") - # 捕获引用以减少全局上下文访问 app = get_app_context() tracker = app.state_tracker controller = app.controller try: while not self._stop_event.is_set(): - # 阶段 1:INPUT - 从事件队列获取消息 (阻塞、100ms超时) try: msg = self.message_queue.get(block=True, timeout=ServerConstants.MAIN_LOOP_POLL_TIMEOUT_SECONDS) except queue.Empty: continue try: - # 阶段 2:PROCESS - 处理事件 result = self._process_event(msg, tracker, controller) - - # 阶段 3:OUTPUT - 分发结果 self._emit_outputs(result, tracker) - except Exception as e: logger.exception(f"Critical error in main loop dispatch: {e}") self._stop_event.wait(1.0) - finally: self.cleanup() return 0 def cleanup(self): - """清理资源并记录详细的关闭日志""" logger.info("Stopping Akagi-NG...") app = get_app_context() - # 停止消息源 + if self.autoplay: + self.autoplay.stop() + for source in filter(None, (app.mitm_client, app.electron_client)): try: logger.info(f"Stopping {source.__class__.__name__}...") @@ -247,7 +241,6 @@ def cleanup(self): except Exception as e: logger.error(f"Error stopping {source.__class__.__name__}: {e}") - # 停止 DataServer if self.ds: try: logger.info("Stopping DataServer...") diff --git a/akagi_backend/akagi_ng/autoplay/__init__.py b/akagi_backend/akagi_ng/autoplay/__init__.py new file mode 100644 index 0000000..65154ec --- /dev/null +++ b/akagi_backend/akagi_ng/autoplay/__init__.py @@ -0,0 +1,3 @@ +from akagi_ng.autoplay.manager import AutoPlayManager, AutoPlayRuntime + +__all__ = ["AutoPlayManager", "AutoPlayRuntime"] diff --git a/akagi_backend/akagi_ng/autoplay/executor.py b/akagi_backend/akagi_ng/autoplay/executor.py new file mode 100644 index 0000000..f6875ee --- /dev/null +++ b/akagi_backend/akagi_ng/autoplay/executor.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import ctypes +import math +import os +import random +import time +from dataclasses import dataclass + +from akagi_ng.core.logging import logger as base_logger +from akagi_ng.schema.constants import Platform +from akagi_ng.settings import local_settings + +logger = base_logger.bind(module="autoplay-executor") + + +@dataclass(slots=True) +class WindowObject: + hwnd: int + name: str + + +@dataclass(slots=True) +class WindowGeometry: + left: int + top: int + width: int + height: int + + +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + + +class RECT(ctypes.Structure): + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] + + +class MouseInput(ctypes.Structure): + _fields_ = [ + ("dx", ctypes.c_long), + ("dy", ctypes.c_long), + ("mouseData", ctypes.c_ulong), + ("dwFlags", ctypes.c_ulong), + ("time", ctypes.c_ulong), + ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), + ] + + +class InputUnion(ctypes.Union): + _fields_ = [("mi", MouseInput)] + + +class INPUT(ctypes.Structure): + _fields_ = [("type", ctypes.c_ulong), ("union", InputUnion)] + + +class WindowsInputExecutor: + def __init__(self): + self._target_hwnd: int | None = None + self._user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None + self._input = ctypes.windll.user32 if hasattr(ctypes, "windll") else None + + @property + def available(self) -> bool: + return self._user32 is not None and os.name == "nt" + + def ensure_target_window(self, platform: Platform | str, custom_keyword: str = "") -> bool: + if not self.available: + return False + if self._check_window(): + logger.info(f"Reusing autoplay target window hwnd={self._target_hwnd}") + return True + selected = self._auto_select_window(platform, custom_keyword) + if selected is None: + logger.warning( + f"Failed to locate autoplay target window for platform={platform} keyword={custom_keyword!r}" + ) + return False + logger.info(f"Selected autoplay target window hwnd={selected.hwnd} title={selected.name!r}") + return True + + def move_to(self, target: tuple[int, int], *, cancel_requested) -> bool: + if not self.available: + return False + start = POINT() + self._user32.GetCursorPos(ctypes.byref(start)) + start_point = (start.x, start.y) + distance = math.hypot(target[0] - start_point[0], target[1] - start_point[1]) + path = self._build_bezier_path(start_point, target) + if not path: + return True + duration = min(0.24, max(0.12, distance / 2200.0)) + step_delay = duration / max(len(path), 1) + for point in path: + if cancel_requested(): + return False + self._user32.SetCursorPos(int(point[0]), int(point[1])) + time.sleep(step_delay) + return True + + def left_click(self) -> None: + if self._input is None: + return + + try: + extra = ctypes.c_ulong(0) + down = INPUT() + down.type = 0 + down.union.mi = MouseInput( + dx=0, + dy=0, + mouseData=0, + dwFlags=0x0002, + time=0, + dwExtraInfo=ctypes.pointer(extra), + ) + up = INPUT() + up.type = 0 + up.union.mi = MouseInput( + dx=0, + dy=0, + mouseData=0, + dwFlags=0x0004, + time=0, + dwExtraInfo=ctypes.pointer(extra), + ) + event_arr = (INPUT * 2)(down, up) + sent = self._input.SendInput(2, ctypes.byref(event_arr), ctypes.sizeof(INPUT)) + if sent != 2: + raise RuntimeError(f"SendInput sent {sent}/2 events") + except Exception: + self._user32.mouse_event(0x0002, 0, 0, 0, 0) + time.sleep(0.012) + self._user32.mouse_event(0x0004, 0, 0, 0, 0) + + def focus_target_window(self) -> None: + if self._target_hwnd is None or self._user32 is None: + return + try: + if self._user32.IsIconic(self._target_hwnd): + self._user32.ShowWindow(self._target_hwnd, 9) + self._user32.SetForegroundWindow(self._target_hwnd) + except Exception: + return + + def get_target_geometry(self) -> WindowGeometry | None: + return self._get_window_geometry(self._target_hwnd) + + def normalized_to_screen(self, geometry: WindowGeometry, coord: tuple[float, float]) -> tuple[int, int]: + scale = min(geometry.width / 16.0, geometry.height / 9.0) + play_width = 16.0 * scale + play_height = 9.0 * scale + offset_x = geometry.left + (geometry.width - play_width) / 2.0 + offset_y = geometry.top + (geometry.height - play_height) / 2.0 + return ( + int(offset_x + coord[0] * scale), + int(offset_y + coord[1] * scale), + ) + + def operation_still_available(self, expected_types: tuple[int, ...], get_operation_list) -> bool: + if not expected_types: + return False + operation_list = get_operation_list() + return any(op.get("type") in expected_types for op in operation_list) + + def click_with_retry(self, target: tuple[int, int], expected_types: tuple[int, ...], get_operation_list, *, cancel_requested) -> bool: + max_attempts = 2 + for attempt in range(1, max_attempts + 1): + if cancel_requested(): + return False + logger.info( + f"Autoplay click attempt {attempt}/{max_attempts} at screen={target} expected_types={expected_types}" + ) + self.left_click() + + if not expected_types or get_operation_list is None: + logger.info("Autoplay click accepted without operation-list verification.") + return True + + for _ in range(6): + if cancel_requested(): + return False + time.sleep(0.1) + if not self.operation_still_available(expected_types, get_operation_list): + logger.info(f"Autoplay click verified on attempt {attempt}/{max_attempts}.") + return True + + if attempt < max_attempts: + logger.warning( + f"Autoplay click attempt {attempt}/{max_attempts} did not clear expected operations; retrying." + ) + jitter_x = random.randint(-2, 2) + jitter_y = random.randint(-2, 2) + self._user32.SetCursorPos(target[0] + jitter_x, target[1] + jitter_y) + time.sleep(0.015) + self._user32.SetCursorPos(target[0], target[1]) + time.sleep(0.015) + logger.warning(f"Autoplay click failed after {max_attempts} attempts at screen={target}.") + return False + + def _check_window(self) -> bool: + if self._target_hwnd is None or self._user32 is None: + return False + return bool(self._user32.IsWindow(self._target_hwnd) and self._user32.IsWindowVisible(self._target_hwnd)) + + def _auto_select_window(self, platform: Platform | str, custom_keyword: str = "") -> WindowObject | None: + keywords = self._window_keywords(platform, custom_keyword) + windows = self._get_windows() + if not windows: + return None + + for window in windows: + lowered = window.name.lower() + if any(keyword in lowered for keyword in keywords): + self._target_hwnd = window.hwnd + return window + + if len(windows) == 1: + self._target_hwnd = windows[0].hwnd + return windows[0] + return None + + def _window_keywords(self, platform: Platform | str, custom_keyword: str = "") -> tuple[str, ...]: + raw_custom = [part.strip().lower() for part in custom_keyword.replace("|", ",").split(",") if part.strip()] + if raw_custom: + return tuple(raw_custom) + + normalized = platform if isinstance(platform, Platform) else Platform(platform) + match normalized: + case Platform.MAJSOUL | Platform.AUTO: + return ("mahjong soul", "majsoul", "jantama", "\u96c0\u9b42") + case Platform.TENHOU: + return ("tenhou", "\u5929\u9cf3") + case Platform.RIICHI_CITY: + return ("riichi city", "\u9ebb\u5c06\u4e00\u756a\u8857") + case Platform.AMATSUKI: + return ("amatsuki", "\u5929\u6708\u9ebb\u5c06") + return ("mahjong", "riichi") + + def _get_windows(self) -> list[WindowObject]: + if self._user32 is None: + return [] + + windows: list[WindowObject] = [] + current_pid = os.getpid() + + @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + def enum_windows_proc(hwnd, _lparam): + if not self._user32.IsWindowVisible(hwnd): + return True + if self._user32.IsIconic(hwnd): + return True + length = self._user32.GetWindowTextLengthW(hwnd) + if length <= 0: + return True + title = ctypes.create_unicode_buffer(length + 1) + self._user32.GetWindowTextW(hwnd, title, length + 1) + name = title.value.strip() + if not name or self._is_ignored_window(name): + return True + pid = ctypes.c_ulong() + self._user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if int(pid.value) == current_pid: + return True + geometry = self._get_window_geometry(int(hwnd)) + if geometry is None or geometry.width < 640 or geometry.height < 360: + return True + windows.append(WindowObject(hwnd=int(hwnd), name=name)) + return True + + self._user32.EnumWindows(enum_windows_proc, 0) + return windows + + def _get_window_geometry(self, hwnd: int | None) -> WindowGeometry | None: + if hwnd is None or self._user32 is None: + return None + rect = RECT() + if not self._user32.GetClientRect(hwnd, ctypes.byref(rect)): + return None + origin = POINT(0, 0) + if not self._user32.ClientToScreen(hwnd, ctypes.byref(origin)): + return None + return WindowGeometry( + left=origin.x, + top=origin.y, + width=rect.right - rect.left, + height=rect.bottom - rect.top, + ) + + def _build_bezier_path(self, start: tuple[int, int], end: tuple[int, int]) -> list[tuple[float, float]]: + dx = end[0] - start[0] + dy = end[1] - start[1] + distance = math.hypot(dx, dy) + if distance < 1: + return [end] + + steps = max(int(local_settings.autoplay.input.bezier_steps), 10) + steps = min(42, steps + max(0, int(distance / 18))) + smoothing = max(0.0, min(local_settings.autoplay.input.bezier_smoothing, 1.0)) + if smoothing <= 0: + return [ + ( + start[0] + dx * index / steps, + start[1] + dy * index / steps, + ) + for index in range(1, steps + 1) + ] + + normal_x = -dy / distance + normal_y = dx / distance + bend = distance * 0.16 * smoothing + control_1 = ( + start[0] + dx * 0.33 + normal_x * bend, + start[1] + dy * 0.33 + normal_y * bend, + ) + control_2 = ( + start[0] + dx * 0.66 - normal_x * bend * 0.75, + start[1] + dy * 0.66 - normal_y * bend * 0.75, + ) + + path: list[tuple[float, float]] = [] + for index in range(1, steps + 1): + t = index / steps + omt = 1.0 - t + x = ( + omt**3 * start[0] + + 3 * omt**2 * t * control_1[0] + + 3 * omt * t**2 * control_2[0] + + t**3 * end[0] + ) + y = ( + omt**3 * start[1] + + 3 * omt**2 * t * control_1[1] + + 3 * omt * t**2 * control_2[1] + + t**3 * end[1] + ) + path.append((x, y)) + return path + + def _is_ignored_window(self, name: str) -> bool: + lowered = name.lower() + return any(token in lowered for token in ("akagi-ng", "akagi ng", "dashboard", "hud")) diff --git a/akagi_backend/akagi_ng/autoplay/manager.py b/akagi_backend/akagi_ng/autoplay/manager.py new file mode 100644 index 0000000..6333488 --- /dev/null +++ b/akagi_backend/akagi_ng/autoplay/manager.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Callable + +from akagi_ng.autoplay.executor import WindowsInputExecutor +from akagi_ng.core.logging import logger as base_logger +from akagi_ng.autoplay.planner import ActionPlanner, PlannedClick +from akagi_ng.schema.constants import Platform +from akagi_ng.schema.protocols import StateTrackerProtocol +from akagi_ng.settings import local_settings + +logger = base_logger.bind(module="autoplay") + + +@dataclass(slots=True) +class AutoPlayRuntime: + platform: Platform + window_keyword: str + get_operation_list: Callable[[], list[dict]] + get_operation_step: Callable[[], int | None] + + +class AutoPlayManager: + def __init__( + self, + runtime_provider: Callable[[], AutoPlayRuntime], + executor: WindowsInputExecutor | None = None, + ): + self._runtime_provider = runtime_provider + self._planner = ActionPlanner() + self._executor = executor or WindowsInputExecutor() + self._task: threading.Thread | None = None + self._stop_event = threading.Event() + self._lock = threading.Lock() + + def observe_event(self, event) -> None: + self._planner.observe_event(event) + if getattr(event, "type", None) in {"start_game", "start_kyoku", "end_kyoku", "end_game"}: + self.stop() + + def execute(self, response: dict | None, tracker: StateTrackerProtocol | None) -> bool: + if not local_settings.autoplay.enabled or response is None or tracker is None: + return False + if not self._executor.available: + logger.warning("Autoplay requested but Windows input executor is unavailable.") + return False + + runtime = self._runtime_provider() + operation_list = runtime.get_operation_list() + operation_step = runtime.get_operation_step() + self._planner.update_operation_list(operation_list) + plan = self._planner.plan( + response, + tracker.tehai_mjai_with_aka, + tracker.last_self_tsumo, + player_state=tracker.player_state, + last_kawa_tile=tracker.last_kawa_tile, + ) + if not plan: + logger.warning( + "Autoplay produced no plan: " + f"response={self._describe_response(response)} " + f"tehai={tracker.tehai_mjai_with_aka} " + f"tsumohai={tracker.last_self_tsumo} " + f"last_kawa={tracker.last_kawa_tile} " + f"operations={self._describe_operation_list(operation_list)} " + f"step={operation_step}" + ) + return False + + logger.info( + "Autoplay plan created: " + f"response={self._describe_response(response)} " + f"tehai={tracker.tehai_mjai_with_aka} " + f"tsumohai={tracker.last_self_tsumo} " + f"last_kawa={tracker.last_kawa_tile} " + f"operations={self._describe_operation_list(operation_list)} " + f"plan={self._describe_plan(plan)} " + f"step={operation_step}" + ) + self.stop() + self._stop_event = threading.Event() + self._task = threading.Thread( + target=self._run_plan, + args=(plan, runtime, operation_step, self._stop_event), + name="AutoPlayTask", + daemon=True, + ) + self._task.start() + return True + + def stop(self) -> None: + with self._lock: + if self._task and self._task.is_alive(): + self._stop_event.set() + self._task.join(timeout=0.2) + self._task = None + + def _run_plan( + self, + plan: list[PlannedClick], + runtime: AutoPlayRuntime, + operation_step: int | None, + stop_event: threading.Event, + ) -> None: + if not self._executor.ensure_target_window(runtime.platform, runtime.window_keyword): + logger.warning("Autoplay aborted: target window not found.") + return + + self._executor.focus_target_window() + for click in plan: + if not self._sleep(click.delay, stop_event): + logger.debug(f"Autoplay cancelled during delay for {click.label}.") + return + if click.requires_operation_step and operation_step is not None: + current_step = runtime.get_operation_step() + if current_step != operation_step: + logger.info( + f"Autoplay aborted: operation step changed for {click.label} " + f"({operation_step} -> {current_step})." + ) + return + + geometry = self._executor.get_target_geometry() + if geometry is None: + logger.warning(f"Autoplay aborted: target geometry unavailable for {click.label}.") + return + + target = self._executor.normalized_to_screen(geometry, click.coord) + logger.info( + "Autoplay executing click: " + f"label={click.label} " + f"delay={click.delay:.3f}s " + f"coord={click.coord} " + f"screen={target} " + f"geometry=({geometry.left},{geometry.top},{geometry.width},{geometry.height}) " + f"expected_types={click.expected_types}" + ) + if not self._executor.move_to(target, cancel_requested=stop_event.is_set): + logger.info(f"Autoplay aborted during cursor movement for {click.label}.") + return + if stop_event.is_set(): + logger.debug(f"Autoplay stop requested before click {click.label}.") + return + + self._executor.focus_target_window() + if not self._executor.click_with_retry( + target, + click.expected_types, + runtime.get_operation_list if runtime.platform == Platform.MAJSOUL else None, + cancel_requested=stop_event.is_set, + ): + logger.warning( + f"Autoplay click did not resolve expected operation for {click.label}: " + f"{click.expected_types}" + ) + return + logger.info(f"Autoplay click succeeded: {click.label}") + + def _sleep(self, delay: float, stop_event: threading.Event) -> bool: + deadline = time.time() + max(delay, 0.0) + while time.time() < deadline: + if stop_event.wait(timeout=0.05): + return False + return not stop_event.is_set() + + def _describe_response(self, response: dict | None) -> str: + if not response: + return "None" + return ( + f"type={response.get('type')} " + f"pai={response.get('pai')} " + f"tsumogiri={response.get('tsumogiri')} " + f"consumed={response.get('consumed')} " + f"reach_dahai={response.get('reach_dahai')}" + ) + + def _describe_operation_list(self, operation_list: list[dict]) -> list[dict]: + return [ + { + "type": item.get("type"), + "combination_count": len(item.get("combination", [])), + "combination_preview": list(item.get("combination", []))[:3], + } + for item in operation_list + ] + + def _describe_plan(self, plan: list[PlannedClick]) -> list[dict]: + return [ + { + "label": click.label, + "coord": click.coord, + "delay": round(click.delay, 3), + "expected_types": click.expected_types, + "requires_step": click.requires_operation_step, + } + for click in plan + ] diff --git a/akagi_backend/akagi_ng/autoplay/planner.py b/akagi_backend/akagi_ng/autoplay/planner.py new file mode 100644 index 0000000..a45a63e --- /dev/null +++ b/akagi_backend/akagi_ng/autoplay/planner.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from functools import cmp_to_key +from itertools import combinations +from typing import Iterable + +from akagi_ng.bridge.majsoul.tile_mapping import MS_TILE_2_MJAI_TILE, compare_pai +from akagi_ng.schema.constants import MahjongConstants +from akagi_ng.schema.protocols import PlayerStateProtocol +from akagi_ng.settings import local_settings + +LOCATION = { + "tiles": [ + (2.23125, 8.3625), + (3.021875, 8.3625), + (3.8125, 8.3625), + (4.603125, 8.3625), + (5.39375, 8.3625), + (6.184375, 8.3625), + (6.975, 8.3625), + (7.765625, 8.3625), + (8.55625, 8.3625), + (9.346875, 8.3625), + (10.1375, 8.3625), + (10.928125, 8.3625), + (11.71875, 8.3625), + (12.509375, 8.3625), + ], + "tsumo_space": 0.246875, + "actions": [ + (10.875, 7.0), + (8.6375, 7.0), + (6.4, 7.0), + (10.875, 5.9), + (8.6375, 5.9), + (6.4, 5.9), + ], + "candidates": [ + (3.6625, 6.3), + (4.49625, 6.3), + (5.33, 6.3), + (6.16375, 6.3), + (6.9975, 6.3), + (7.83125, 6.3), + (8.665, 6.3), + (9.49875, 6.3), + (10.3325, 6.3), + (11.16625, 6.3), + (12.0, 6.3), + ], + "candidates_kan": [ + (4.325, 6.3), + (5.4915, 6.3), + (6.6583, 6.3), + (7.825, 6.3), + (8.9917, 6.3), + (10.1583, 6.3), + (11.325, 6.3), + ], +} + +ACTION_PRIORITY = { + 0: 0, + 1: 99, + 2: 4, + 3: 3, + 4: 3, + 5: 2, + 6: 3, + 7: 2, + 8: 1, + 9: 1, + 10: 4, + 11: 2, +} + +ACTION2TYPE = { + "none": 0, + "chi": 2, + "pon": 3, + "ankan": 4, + "daiminkan": 5, + "kakan": 6, + "reach": 7, + "ryukyoku": 10, + "nukidora": 11, +} + +BUTTON_ACTIONS = set(ACTION2TYPE) | {"hora", "tsumo", "ron"} + + +@dataclass(slots=True) +class PlannedClick: + coord: tuple[float, float] + delay: float + label: str + expected_types: tuple[int, ...] = () + requires_operation_step: bool = False + + +class ActionPlanner: + def __init__(self) -> None: + self.is_new_round = True + self.reached = False + self.pending_reach_discard = False + self.latest_operation_list: list[dict] = [] + + def observe_event(self, event) -> None: + event_type = getattr(event, "type", None) + if event_type in {"start_game", "start_kyoku", "end_kyoku", "end_game"}: + self.is_new_round = True + self.reached = False + self.pending_reach_discard = False + + def update_operation_list(self, operation_list: list[dict] | None) -> None: + self.latest_operation_list = operation_list or [] + + def discard_delay(self) -> float: + timing = local_settings.autoplay.timing + if self.is_new_round: + return timing.first_tile + low = min(timing.rand_min, timing.rand_max) + high = max(timing.rand_min, timing.rand_max) + return random.uniform(low, high) + + def action_delay(self) -> float: + return max(local_settings.autoplay.timing.candidate, 0.12) + 0.8 + + def candidate_delay(self) -> float: + return max(local_settings.autoplay.timing.candidate, 0.08) + + def get_pai_coord(self, idx: int, tehais: list[str]) -> tuple[float, float]: + visible_count = sum(1 for tehai in tehais if tehai != "?") + last_tile_index = len(LOCATION["tiles"]) - 1 + if idx >= MahjongConstants.TEHAI_SIZE: + base_index = min(visible_count, last_tile_index) + base = LOCATION["tiles"][base_index] + return (base[0] + LOCATION["tsumo_space"], base[1]) + return LOCATION["tiles"][min(idx, last_tile_index)] + + def plan( + self, + mjai_msg: dict | None, + tehai: list[str], + tsumohai: str | None, + *, + player_state: PlayerStateProtocol | None = None, + last_kawa_tile: str | None = None, + ) -> list[PlannedClick]: + if mjai_msg is None: + return [] + + action_type = mjai_msg.get("type") + if action_type == "dahai" and (not self.reached or self.pending_reach_discard): + coord = self._find_discard_coord( + mjai_msg["pai"], + list(tehai), + tsumohai, + prefer_tsumo=bool(mjai_msg.get("tsumogiri")), + ) + if coord is None: + return [] + label = "reach-discard" if self.pending_reach_discard else "discard" + self.pending_reach_discard = False + self.is_new_round = False + return [ + PlannedClick( + coord=coord, + delay=self.discard_delay(), + label=label, + expected_types=(1,), + requires_operation_step=False, + ) + ] + + if action_type not in BUTTON_ACTIONS: + return [] + + operation_list = self._sorted_operation_list(player_state, last_kawa_tile, action_type, tehai, tsumohai) + action_index = self._find_action_index(operation_list, self._operation_types_for_action(action_type)) + if action_index is None: + return [] + + click_label = "skip" if action_type == "none" else action_type + plan = [ + PlannedClick( + coord=LOCATION["actions"][action_index], + delay=self.action_delay(), + label=click_label, + expected_types=self._expected_types_for_action(action_type), + requires_operation_step=True, + ) + ] + + if action_type == "reach": + self.reached = True + pai = mjai_msg.get("pai") + if pai is None and isinstance(mjai_msg.get("reach_dahai"), dict): + pai = mjai_msg["reach_dahai"].get("pai") + if pai is None: + self.pending_reach_discard = True + return plan + + discard_coord = self._find_discard_coord( + pai, + list(tehai), + tsumohai, + prefer_tsumo=bool(mjai_msg.get("tsumogiri")), + ) + if discard_coord is None: + self.pending_reach_discard = True + return plan + + self.is_new_round = False + self.pending_reach_discard = False + plan.append( + PlannedClick( + coord=discard_coord, + delay=self.candidate_delay(), + label="reach-discard", + expected_types=(1,), + requires_operation_step=False, + ) + ) + return plan + + if action_type in {"chi", "pon", "ankan", "kakan"}: + candidate_click = self._plan_candidate_click(operation_list, mjai_msg) + if candidate_click is not None: + plan.append(candidate_click) + return plan + + def _find_discard_coord( + self, + dahai: str, + tehai: list[str], + tsumohai: str | None, + prefer_tsumo: bool = False, + ) -> tuple[float, float] | None: + normalized_tehai, detached_tile = self._normalize_visible_hand(tehai, tsumohai) + + if self.is_new_round: + if prefer_tsumo and detached_tile and self._tile_matches(dahai, detached_tile): + return self.get_pai_coord(MahjongConstants.TEHAI_SIZE, normalized_tehai) + + for idx, tile in enumerate(normalized_tehai): + if self._tile_matches(dahai, tile): + return self.get_pai_coord(idx, normalized_tehai) + + if detached_tile and self._tile_matches(dahai, detached_tile): + return self.get_pai_coord(MahjongConstants.TEHAI_SIZE, normalized_tehai) + + if detached_tile and self._tile_matches(dahai, detached_tile): + return self.get_pai_coord(MahjongConstants.TEHAI_SIZE, normalized_tehai) + + for idx, tile in enumerate(normalized_tehai): + if self._tile_matches(dahai, tile): + return self.get_pai_coord(idx, normalized_tehai) + + if prefer_tsumo: + return self.get_pai_coord(MahjongConstants.TEHAI_SIZE, normalized_tehai) + return None + + def _sorted_operation_list( + self, + player_state: PlayerStateProtocol | None, + last_kawa_tile: str | None, + requested_type: str, + tehai: list[str], + tsumohai: str | None, + ) -> list[dict]: + operations = self.latest_operation_list or self._fallback_operation_list( + player_state, last_kawa_tile, requested_type, tehai, tsumohai + ) + operations = [operation.copy() for operation in operations] + operations.append({"type": 0, "combination": []}) + + can_ankan = any(operation["type"] == 4 for operation in operations) + can_kakan = any(operation["type"] == 6 for operation in operations) + if can_ankan and can_kakan: + ankan_combinations = [ + combination + for operation in operations + if operation["type"] == 4 + for combination in operation.get("combination", []) + ] + merged_operations: list[dict] = [] + for operation in operations: + if operation["type"] == 4: + continue + if operation["type"] == 6: + updated = operation.copy() + updated["combination"] = list(updated.get("combination", [])) + ankan_combinations + merged_operations.append(updated) + continue + merged_operations.append(operation) + operations = merged_operations + + operations.sort(key=lambda item: ACTION_PRIORITY.get(item["type"], 99)) + return operations + + def _find_action_index(self, operations: list[dict], action_types: tuple[int, ...]) -> int | None: + for idx, operation in enumerate(operations): + if operation["type"] in action_types: + return idx + return None + + def _plan_candidate_click(self, operation_list: list[dict], mjai_msg: dict) -> PlannedClick | None: + action_type = mjai_msg["type"] + consumed = sorted(mjai_msg.get("consumed", []), key=cmp_to_key(compare_pai)) + target_type = ACTION2TYPE[action_type] + + for operation in operation_list: + if operation["type"] != target_type and not ( + action_type in {"ankan", "kakan"} and operation["type"] == 6 + ): + continue + + combinations_raw = list(operation.get("combination", [])) + if len(combinations_raw) <= 1: + return None + + candidate_slots = LOCATION["candidates_kan"] if action_type in {"ankan", "kakan"} else LOCATION["candidates"] + mid_point = 3 if action_type in {"ankan", "kakan"} else 5 + for idx, combination in enumerate(combinations_raw): + normalized = sorted(self._normalize_combination(combination), key=cmp_to_key(compare_pai)) + if normalized != consumed: + continue + candidate_index = int((-(len(combinations_raw) / 2) + idx + 0.5) * 2 + mid_point) + if 0 <= candidate_index < len(candidate_slots): + return PlannedClick( + coord=candidate_slots[candidate_index], + delay=self.candidate_delay(), + label=f"{action_type}-candidate", + expected_types=self._operation_types_for_action(action_type), + requires_operation_step=True, + ) + return None + + def _fallback_operation_list( + self, + player_state: PlayerStateProtocol | None, + last_kawa_tile: str | None, + requested_type: str, + tehai: list[str], + tsumohai: str | None, + ) -> list[dict]: + if player_state is None: + fallback_type = self._operation_types_for_action(requested_type) + return [{"type": fallback_type[0], "combination": []}] if fallback_type else [] + + operations: list[dict] = [] + hand = self._full_hand(tehai, tsumohai) + cans = player_state.last_cans + + chi_combinations = self._build_chi_combinations(last_kawa_tile, hand, cans) + if chi_combinations: + operations.append({"type": 2, "combination": chi_combinations}) + + pon_combinations = self._build_same_tile_combinations(last_kawa_tile, hand, 2) + if cans.can_pon and pon_combinations: + operations.append({"type": 3, "combination": pon_combinations}) + + ankan_combinations = self._build_ankan_combinations(tehai, tsumohai) + if cans.can_ankan and ankan_combinations: + operations.append({"type": 4, "combination": ankan_combinations}) + + daiminkan_combinations = self._build_same_tile_combinations(last_kawa_tile, hand, 3) + if cans.can_daiminkan and daiminkan_combinations: + operations.append({"type": 5, "combination": daiminkan_combinations}) + + kakan_combinations = self._build_kakan_combinations(player_state, hand) + if cans.can_kakan and kakan_combinations: + operations.append({"type": 6, "combination": kakan_combinations}) + + if cans.can_riichi: + operations.append({"type": 7, "combination": []}) + if cans.can_tsumo_agari or cans.can_ron_agari: + operations.append({"type": 9 if cans.can_ron_agari else 8, "combination": []}) + if cans.can_ryukyoku: + operations.append({"type": 10, "combination": []}) + if requested_type == "nukidora": + operations.append({"type": 11, "combination": []}) + return operations + + def _operation_types_for_action(self, action_type: str) -> tuple[int, ...]: + if action_type in {"hora", "tsumo", "ron"}: + return (8, 9) + if action_type not in ACTION2TYPE: + return () + return (ACTION2TYPE[action_type],) + + def _expected_types_for_action(self, action_type: str) -> tuple[int, ...]: + if action_type == "none": + pending_types = tuple(op["type"] for op in self.latest_operation_list if op.get("type", 0) != 0) + return pending_types + return self._operation_types_for_action(action_type) + + def _build_ankan_combinations(self, tehai: list[str], tsumohai: str | None) -> list[str]: + full_hand = self._full_hand(tehai, tsumohai) + grouped: dict[str, list[str]] = {} + for tile in full_hand: + grouped.setdefault(tile.replace("r", ""), []).append(tile) + + combinations_out: list[str] = [] + for base_tile, tiles in grouped.items(): + if len(tiles) < 4: + continue + ordered = list(sorted(tiles, key=cmp_to_key(compare_pai))) + while len(ordered) < 4: + ordered.append(base_tile) + combinations_out.append("|".join(ordered[:4])) + return combinations_out + + def _build_kakan_combinations(self, player_state: PlayerStateProtocol, hand: list[str]) -> list[str]: + combinations_out: list[str] = [] + for cand in player_state.kakan_candidates(): + combinations_out.extend(self._build_tile_selection_combinations([cand.replace("r", "")], hand)) + return list(dict.fromkeys(combinations_out)) + + def _build_chi_combinations(self, last_kawa_tile: str | None, hand: list[str], cans) -> list[str]: + if not last_kawa_tile: + return [] + base = last_kawa_tile.replace("r", "") + try: + num = int(base[0]) + suit = base[1] + except (ValueError, IndexError): + return [] + + targets_by_type = { + "chi_low": [f"{num + 1}{suit}", f"{num + 2}{suit}"], + "chi_mid": [f"{num - 1}{suit}", f"{num + 1}{suit}"], + "chi_high": [f"{num - 2}{suit}", f"{num - 1}{suit}"], + } + + combinations_out: list[str] = [] + for chi_type, targets in targets_by_type.items(): + if not getattr(cans, f"can_{chi_type}", False): + continue + if any(target[0] not in "123456789" for target in targets): + continue + if not all("1" <= target[0] <= "9" and target[1] == suit for target in targets): + continue + combinations_out.extend(self._build_tile_selection_combinations(targets, hand)) + return list(dict.fromkeys(combinations_out)) + + def _build_same_tile_combinations(self, tile: str | None, hand: list[str], count: int) -> list[str]: + if not tile: + return [] + return self._build_tile_selection_combinations([tile.replace("r", "")] * count, hand) + + def _build_tile_selection_combinations(self, targets: list[str], hand: list[str]) -> list[str]: + if not targets: + return [] + + indexed_hand = list(enumerate(hand)) + combinations_out: set[str] = set() + for picked in combinations(indexed_hand, len(targets)): + tiles = [tile for _idx, tile in picked] + if self._matches_target_multiset(tiles, targets): + normalized = sorted(tiles, key=cmp_to_key(compare_pai)) + combinations_out.add("|".join(normalized)) + return sorted(combinations_out) + + def _matches_target_multiset(self, tiles: list[str], targets: list[str]) -> bool: + remaining = list(targets) + for tile in tiles: + matched = False + for idx, target in enumerate(remaining): + if self._tile_matches(target, tile): + remaining.pop(idx) + matched = True + break + if not matched: + return False + return not remaining + + def _normalize_combination(self, combination: str | Iterable[str]) -> list[str]: + if isinstance(combination, str): + tiles = combination.split("|") + else: + tiles = list(combination) + return [MS_TILE_2_MJAI_TILE.get(tile, tile) for tile in tiles] + + def _full_hand(self, tehai: list[str], tsumohai: str | None) -> list[str]: + hand = [tile for tile in tehai if tile != "?"] + if tsumohai and tsumohai != "?": + hand.append(tsumohai) + return hand + + def _normalize_visible_hand(self, tehai: list[str], tsumohai: str | None) -> tuple[list[str], str | None]: + sorted_tehai = sorted((tile for tile in tehai if tile != "?"), key=cmp_to_key(compare_pai)) + detached_tile = tsumohai if tsumohai and tsumohai != "?" else None + + if detached_tile is not None: + detached_index = self._find_exact_or_matching_index(sorted_tehai, detached_tile) + if detached_index is not None: + sorted_tehai.pop(detached_index) + elif len(sorted_tehai) > MahjongConstants.TEHAI_SIZE: + detached_tile = sorted_tehai.pop() + + if len(sorted_tehai) > MahjongConstants.TEHAI_SIZE: + sorted_tehai = sorted_tehai[: MahjongConstants.TEHAI_SIZE] + + return sorted_tehai, detached_tile + + def _find_exact_or_matching_index(self, tiles: list[str], target: str) -> int | None: + for idx, tile in enumerate(tiles): + if tile == target: + return idx + for idx, tile in enumerate(tiles): + if self._tile_matches(target, tile): + return idx + return None + + def _tile_matches(self, target: str, current: str) -> bool: + if target == current: + return True + if target.endswith("r"): + return target[:2] == current[:2] + if current.endswith("r"): + return target[:2] == current[:2] + return target == current diff --git a/akagi_backend/akagi_ng/bridge/majsoul/bridge.py b/akagi_backend/akagi_ng/bridge/majsoul/bridge.py index 19b65fd..12c2b06 100644 --- a/akagi_backend/akagi_ng/bridge/majsoul/bridge.py +++ b/akagi_backend/akagi_ng/bridge/majsoul/bridge.py @@ -28,6 +28,8 @@ def _init_state(self): self.doras = [] self.my_tehais = ["?"] * MahjongConstants.TEHAI_SIZE self.my_tsumohai = "?" + self.latest_self_operation_list: list[dict] = [] + self.latest_operation_step: int | None = None self.syncing = False self.mode_id = -1 @@ -40,6 +42,44 @@ def _init_state(self): def reset(self): self._init_state() + def _clear_self_operation_state(self) -> None: + self.latest_self_operation_list = [] + self.latest_operation_step = None + + def _capture_self_operation_list(self, liqi_message: dict) -> None: + wrapper = liqi_message.get("data", {}) + payload = wrapper.get("data", {}) + step = wrapper.get("step") + operation = payload.get("operation") + if not operation: + # Majsoul only sends self operationList while the decision window is active. + # Once a later action arrives without operation data, the previous self-operation + # window has expired and stale buttons must be cleared for autoplay retry logic. + if self.latest_self_operation_list or self.latest_operation_step is not None: + logger.debug( + f"[Majsoul] Clearing stale self operation list at step={step}, seat={payload.get('seat')}" + ) + self._clear_self_operation_state() + return + + seat = int(operation.get("seat", -1)) + if seat != self.seat: + return + + operation_list = operation.get("operationList", operation.get("operation_list", [])) + self.latest_self_operation_list = [ + { + "type": int(item.get("type", 0)), + "combination": list(item.get("combination", [])), + } + for item in operation_list + ] + self.latest_operation_step = int(step) if step is not None else None + logger.debug( + f"[Majsoul] Captured self operation list at step={self.latest_operation_step}: " + f"{[item['type'] for item in self.latest_self_operation_list]}" + ) + def parse(self, content: bytes) -> list[AkagiEvent]: """解析内容并返回 MJAI 指令。 @@ -411,6 +451,7 @@ def _handle_action_ba_bei(self, action_data: dict) -> list[MJAIEvent]: def _handle_action_prototype(self, liqi_message: dict) -> list[MJAIEvent]: """处理ActionPrototype相关的所有动作""" ret: list[MJAIEvent] = [] + self._capture_self_operation_list(liqi_message) action_data = liqi_message["data"] action_name = action_data["name"] diff --git a/akagi_backend/akagi_ng/core/lib_loader.py b/akagi_backend/akagi_ng/core/lib_loader.py index 680c3d3..052bc01 100644 --- a/akagi_backend/akagi_ng/core/lib_loader.py +++ b/akagi_backend/akagi_ng/core/lib_loader.py @@ -1,20 +1,75 @@ +from __future__ import annotations + +import importlib.util +import platform import sys +from pathlib import Path +from types import ModuleType from akagi_ng.core.paths import get_lib_dir -# 将 lib 目录添加到 sys.path 以便导入二进制文件 lib_dir = get_lib_dir() if str(lib_dir) not in sys.path: - # 前置到 sys.path 以确保优先从此处加载 sys.path.insert(0, str(lib_dir)) -try: - import libriichi - import libriichi3p -except ImportError as e: - # 如果目录存在但导入失败,可能是缺少或不兼容的二进制文件 + +def _platform_suffix() -> str: + system = platform.system() + machine = platform.machine().lower() + python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + if machine in {"arm64", "aarch64"}: + arch = "aarch64" + else: + arch = "x86_64" + + if system == "Windows": + return f"{python_version}-{arch}-pc-windows-msvc.pyd" + if system == "Darwin": + return f"{python_version}-{arch}-apple-darwin.so" + if system == "Linux": + return f"{python_version}-{arch}-unknown-linux-gnu.so" + raise ImportError(f"Unsupported platform for libriichi loading: {system}/{machine}") + + +def _candidate_paths(module_name: str) -> list[Path]: + suffix = _platform_suffix() + extension = ".pyd" if platform.system() == "Windows" else ".so" + return [ + lib_dir / f"{module_name}{extension}", + lib_dir / f"{module_name}-{suffix}", + ] + + +def _load_extension_module(module_name: str) -> ModuleType: + for candidate in _candidate_paths(module_name): + if not candidate.exists(): + continue + + spec = importlib.util.spec_from_file_location(module_name, candidate) + if spec is None or spec.loader is None: + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + searched = ", ".join(str(path) for path in _candidate_paths(module_name)) raise ImportError( - f"Failed to load libriichi/libriichi3p from {lib_dir}. Ensure the .pyd/.so files are present." - ) from e + f"Failed to load {module_name} from {lib_dir}. Checked: {searched}" + ) + + +try: + import libriichi # type: ignore[no-redef] +except ImportError: + libriichi = _load_extension_module("libriichi") + +try: + import libriichi3p # type: ignore[no-redef] +except ImportError: + libriichi3p = _load_extension_module("libriichi3p") + __all__ = ["libriichi", "libriichi3p"] diff --git a/akagi_backend/akagi_ng/schema/types.py b/akagi_backend/akagi_ng/schema/types.py index 4054a63..cbb7682 100644 --- a/akagi_backend/akagi_ng/schema/types.py +++ b/akagi_backend/akagi_ng/schema/types.py @@ -97,8 +97,10 @@ class MortalModelResource: "ankan", "kakan", "tsumo", + "ron", "hora", "ryukyoku", + "nukidora", ] type ChiType = Literal["chi_low", "chi_mid", "chi_high"] diff --git a/akagi_backend/akagi_ng/settings/settings.py b/akagi_backend/akagi_ng/settings/settings.py index b7a83e4..10a5561 100644 --- a/akagi_backend/akagi_ng/settings/settings.py +++ b/akagi_backend/akagi_ng/settings/settings.py @@ -54,6 +54,28 @@ class ModelConfig: model_3p: str = "mortal3p.pth" +@dataclass(slots=True) +class AutoplayTimingConfig: + first_tile: float + rand_min: float + rand_max: float + candidate: float + + +@dataclass(slots=True) +class AutoplayInputConfig: + bezier_smoothing: float + bezier_steps: int + + +@dataclass(slots=True) +class AutoplayConfig: + enabled: bool + window_keyword: str + timing: AutoplayTimingConfig + input: AutoplayInputConfig + + @dataclass(slots=True) class Settings: log_level: str @@ -64,6 +86,7 @@ class Settings: server: ServerConfig ot: OTConfig model_config: ModelConfig + autoplay: AutoplayConfig def update(self, data: dict): """从字典更新设置""" @@ -94,6 +117,9 @@ def from_dict(cls, data: dict) -> Self: server_data = data.get("server", {}) model_config_data = data.get("model_config", {}) ot_data = data.get("ot", {}) + autoplay_data = data.get("autoplay", {}) + autoplay_timing = autoplay_data.get("timing", {}) + autoplay_input = autoplay_data.get("input", {}) game_url = data.get("game_url", "") platform_val = data.get("platform") @@ -124,6 +150,20 @@ def from_dict(cls, data: dict) -> Self: model_3p=model_config_data.get("model_3p", "mortal3p.pth"), temperature=model_config_data.get("temperature", 0.3), ), + autoplay=AutoplayConfig( + enabled=autoplay_data.get("enabled", False), + window_keyword=autoplay_data.get("window_keyword", ""), + timing=AutoplayTimingConfig( + first_tile=autoplay_timing.get("first_tile", 5.0), + rand_min=autoplay_timing.get("rand_min", 1.0), + rand_max=autoplay_timing.get("rand_max", 3.0), + candidate=autoplay_timing.get("candidate", 0.5), + ), + input=AutoplayInputConfig( + bezier_smoothing=autoplay_input.get("bezier_smoothing", 0.35), + bezier_steps=autoplay_input.get("bezier_steps", 18), + ), + ), ) @@ -199,6 +239,20 @@ def get_default_settings_dict() -> dict: "model_3p": "mortal3p.pth", "temperature": 0.3, }, + "autoplay": { + "enabled": False, + "window_keyword": "", + "timing": { + "first_tile": 5.0, + "rand_min": 1.0, + "rand_max": 3.0, + "candidate": 0.5, + }, + "input": { + "bezier_smoothing": 0.35, + "bezier_steps": 18, + }, + }, } @@ -283,6 +337,18 @@ def _update_settings(settings: Settings, data: dict): settings.model_config.model_3p = model_config_data.get("model_3p", "mortal3p.pth") settings.model_config.temperature = model_config_data.get("temperature", 0.3) + autoplay_data = data.get("autoplay", {}) + settings.autoplay.enabled = autoplay_data.get("enabled", False) + settings.autoplay.window_keyword = autoplay_data.get("window_keyword", "") + autoplay_timing = autoplay_data.get("timing", {}) + settings.autoplay.timing.first_tile = autoplay_timing.get("first_tile", 5.0) + settings.autoplay.timing.rand_min = autoplay_timing.get("rand_min", 1.0) + settings.autoplay.timing.rand_max = autoplay_timing.get("rand_max", 3.0) + settings.autoplay.timing.candidate = autoplay_timing.get("candidate", 0.5) + autoplay_input = autoplay_data.get("input", {}) + settings.autoplay.input.bezier_smoothing = autoplay_input.get("bezier_smoothing", 0.35) + settings.autoplay.input.bezier_steps = autoplay_input.get("bezier_steps", 18) + ot_data = data.get("ot", {}) settings.ot.online = ot_data.get("online", False) settings.ot.server = ot_data.get("server", "") diff --git a/akagi_backend/scripts/build_backend.py b/akagi_backend/scripts/build_backend.py index b513146..2862ae8 100644 --- a/akagi_backend/scripts/build_backend.py +++ b/akagi_backend/scripts/build_backend.py @@ -1,4 +1,4 @@ -import platform +import platform import shutil import subprocess import sys @@ -30,7 +30,7 @@ def get_download_url() -> str: def write_version_to_dest(backend_root: Path, packages_dest: Path) -> str: - print(" 🔖 Generating version file inside the bundle...") + print(" Generating version file inside the bundle...") pyproject_path = backend_root / "pyproject.toml" version = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"]["version"] version_file_path = packages_dest / "akagi_ng" / "_version.py" @@ -46,17 +46,13 @@ def write_version_to_dest(backend_root: Path, packages_dest: Path) -> str: def cleanup_python_dist(python_dir: Path): - print(" 🧹 Sweeping Python standard library bloat...") - # Delete C/C++ headers + print(" Sweeping Python standard library bloat...") for p in python_dir.glob("**/include"): shutil.rmtree(p, ignore_errors=True) - # Delete static libraries for p in python_dir.rglob("*.a"): p.unlink(missing_ok=True) - # Delete manual pages for p in python_dir.glob("**/share"): shutil.rmtree(p, ignore_errors=True) - # Delete useless stdlib packages for useless_dir in ["idlelib", "tkinter", "turtledemo", "test"]: for p in python_dir.rglob(useless_dir): if p.is_dir(): @@ -64,8 +60,7 @@ def cleanup_python_dist(python_dir: Path): def cleanup_app_packages(app_packages_dir: Path): - print(" 🧹 Cleaning up bloated files...") - # Delete C/C++ source files in packages + print(" Cleaning up bloated files...") for p in app_packages_dir.rglob("*.c"): p.unlink(missing_ok=True) for p in app_packages_dir.rglob("*.cpp"): @@ -76,11 +71,9 @@ def cleanup_app_packages(app_packages_dir: Path): p.unlink(missing_ok=True) for p in app_packages_dir.rglob("*.hpp"): p.unlink(missing_ok=True) - # PyTorch and NumPy include massive C++ header directories useless at runtime for p in app_packages_dir.glob("**/include"): if p.is_dir(): shutil.rmtree(p, ignore_errors=True) - # Delete pip metadata for p in app_packages_dir.glob("*.dist-info"): shutil.rmtree(p, ignore_errors=True) for p in app_packages_dir.glob("*.egg-info"): @@ -89,19 +82,19 @@ def cleanup_app_packages(app_packages_dir: Path): def download_and_extract_python(dist_dir: Path) -> None: url = get_download_url() - print(f" ⬇️ Downloading Portable Python from: {url}") + print(f" Downloading portable Python from: {url}") temp_path = Path(tempfile.gettempdir()) / "portable_python.tar.gz" urllib.request.urlretrieve(url, temp_path) - print(" 📦 Extracting Portable Python...") + print(" Extracting portable Python...") shutil.unpack_archive(temp_path, extract_dir=dist_dir, format="gztar", filter="data") temp_path.unlink(missing_ok=True) def install_dependencies_and_compile(backend_root: Path, packages_dest: Path) -> None: - print(f" 📥 Exporting Project and Dependencies to {packages_dest}...") + print(f" Exporting project and dependencies to {packages_dest}...") cmd = [ sys.executable, "-m", @@ -114,27 +107,24 @@ def install_dependencies_and_compile(backend_root: Path, packages_dest: Path) -> subprocess.run(cmd, cwd=backend_root, check=True) - print(" ⚡ Pre-compiling Python bytecode for extreme startup performance...") - # 提前把所有源码编译成 .pyc 保存到硬盘,避免用户在终端(尤其是 Mac 只读目录)第一次打开时疯狂消耗 CPU 编译 + print(" Pre-compiling Python bytecode for startup performance...") subprocess.run([sys.executable, "-m", "compileall", "-q", str(packages_dest)], check=False) def patch_and_rename_binaries(dist_dir: Path, backend_root: Path, version_str: str) -> None: - print(" 🪪 Renaming binaries for OS process identification...") + print(" Renaming binaries for OS process identification...") python_dir = dist_dir / "python" - # Windows win_exe = python_dir / "python.exe" win_w_exe = python_dir / "pythonw.exe" if win_exe.exists(): akagi_exe = python_dir / "akagi-ng.exe" win_exe.rename(akagi_exe) - # 修改 exe 图标和文件描述信息 rcedit_path = backend_root.parent / "node_modules" / "electron-winstaller" / "vendor" / "rcedit.exe" icon_path = backend_root.parent / "assets" / "torii.ico" if rcedit_path.exists() and icon_path.exists(): - print(f" 🎨 Patching icon and metadata for {akagi_exe.name}...") + print(f" Patching icon and metadata for {akagi_exe.name}...") subprocess.run( [ str(rcedit_path), @@ -170,14 +160,12 @@ def patch_and_rename_binaries(dist_dir: Path, backend_root: Path, version_str: s if win_w_exe.exists(): win_w_exe.unlink() - # Unix (Mac/Linux) unix_bin_dir = python_dir / "bin" unix_exe = unix_bin_dir / "python3" if unix_exe.exists(): real_exe = unix_exe.resolve() if real_exe.exists(): real_exe.rename(unix_bin_dir / "akagi-ng") - # 移除所有旧的软链接 for item in unix_bin_dir.iterdir(): if item.is_symlink(): item.unlink(missing_ok=True) @@ -191,7 +179,7 @@ def main(): dist_dir = project_root / "dist" / "backend" / "akagi-ng" packages_dest = dist_dir / "app_packages" - print("📦 Building Akagi-NG Portable Backend...") + print("Building Akagi-NG Portable Backend...") if dist_dir.exists(): shutil.rmtree(dist_dir) @@ -206,7 +194,7 @@ def main(): version_str = write_version_to_dest(backend_root, packages_dest) patch_and_rename_binaries(dist_dir, backend_root, version_str) - print(f"✅ Backend build successful! Portable backend is at: {dist_dir}") + print(f"Backend build successful. Portable backend is at: {dist_dir}") if __name__ == "__main__": diff --git a/akagi_backend/tests/unit/test_autoplay_planner.py b/akagi_backend/tests/unit/test_autoplay_planner.py new file mode 100644 index 0000000..044f07e --- /dev/null +++ b/akagi_backend/tests/unit/test_autoplay_planner.py @@ -0,0 +1,142 @@ +from types import SimpleNamespace + +import pytest + +from akagi_ng.autoplay.planner import ActionPlanner +from akagi_ng.schema.constants import MahjongConstants + + +def _mock_cans(**overrides): + defaults = { + "can_discard": True, + "can_riichi": False, + "can_chi": False, + "can_chi_low": False, + "can_chi_mid": False, + "can_chi_high": False, + "can_pon": False, + "can_kan": False, + "can_ankan": False, + "can_kakan": False, + "can_daiminkan": False, + "can_tsumo_agari": False, + "can_ron_agari": False, + "can_ryukyoku": False, + } + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def _mock_player_state(*, cans=None, kakan_candidates=None): + return SimpleNamespace( + last_cans=cans or _mock_cans(), + kakan_candidates=lambda: list(kakan_candidates or []), + ) + + +def test_plan_tsumogiri_discard_targets_drawn_tile(): + planner = ActionPlanner() + + plan = planner.plan( + {"type": "dahai", "pai": "5m", "tsumogiri": True}, + ["1m", "2m", "3m", "4m", "6m", "7m", "8m", "9m", "1p", "2p", "3p", "4p", "5p"], + "5m", + ) + + assert len(plan) == 1 + assert plan[0].label == "discard" + assert plan[0].expected_types == (1,) + + +def test_plan_reach_click_appends_follow_up_discard(): + planner = ActionPlanner() + planner.update_operation_list([{"type": 7, "combination": []}]) + + plan = planner.plan( + {"type": "reach", "pai": "1m"}, + ["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", "1p", "2p", "3p", "4p"], + None, + ) + + assert [click.label for click in plan] == ["reach", "reach-discard"] + assert planner.reached is True + assert planner.pending_reach_discard is False + + +def test_plan_nukidora_button_supported(): + planner = ActionPlanner() + planner.update_operation_list([{"type": 11, "combination": []}]) + + plan = planner.plan( + {"type": "nukidora"}, + ["N", "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", "1p", "2p", "3p"], + None, + ) + + assert len(plan) == 1 + assert plan[0].label == "nukidora" + assert plan[0].expected_types == (11,) + + +def test_plan_chi_candidate_uses_fallback_operation_reconstruction(): + planner = ActionPlanner() + player_state = _mock_player_state( + cans=_mock_cans(can_chi=True, can_chi_low=True, can_chi_mid=True, can_chi_high=True) + ) + + plan = planner.plan( + {"type": "chi", "consumed": ["1m", "2m"]}, + ["1m", "2m", "2m", "4m", "4m", "5m", "6m", "7m", "8m", "9m", "1p", "2p", "3p"], + None, + player_state=player_state, + last_kawa_tile="3m", + ) + + assert len(plan) == 2 + assert plan[0].label == "chi" + assert plan[1].label == "chi-candidate" + + +def test_plan_opening_discard_after_nukidora_targets_tsumo_slot_without_overflow(): + planner = ActionPlanner() + + tehai = ["2p", "3p", "4p", "5p", "8p", "9p", "2s", "2s", "3s", "3s", "3s", "P", "P", "P"] + plan = planner.plan( + {"type": "dahai", "pai": "C", "tsumogiri": False}, + tehai, + "C", + ) + + assert len(plan) == 1 + assert plan[0].label == "discard" + assert plan[0].coord == planner.get_pai_coord(MahjongConstants.TEHAI_SIZE, tehai) + + +def test_get_pai_coord_clamps_overflow_indexes_to_tsumo_slot(): + planner = ActionPlanner() + tehai = ["1m"] * 14 + + assert planner.get_pai_coord(14, tehai) == planner.get_pai_coord(MahjongConstants.TEHAI_SIZE, tehai) + + +def test_get_pai_coord_uses_shortened_tsumo_slot_after_open_meld(): + planner = ActionPlanner() + tehai = ["2p", "3p", "4p", "5pr", "6p", "6p", "4s", "4s", "6s", "8s", "E"] + + assert planner.get_pai_coord(MahjongConstants.TEHAI_SIZE, tehai) == pytest.approx((11.175, 8.3625)) + + +def test_plan_discard_finds_last_hand_tile_when_tracker_tehai_already_has_14_tiles(): + planner = ActionPlanner() + planner.is_new_round = False + + tehai = ["1p", "2p", "3p", "7p", "7p", "8p", "9p", "3s", "7s", "9s", "9s", "S", "S", "P"] + plan = planner.plan( + {"type": "dahai", "pai": "P", "tsumogiri": False}, + tehai, + "1p", + ) + + assert len(plan) == 1 + assert plan[0].label == "discard" + assert plan[0].coord == planner.get_pai_coord(12, tehai) diff --git a/akagi_backend/tests/unit/test_bridge_majsoul_autoplay.py b/akagi_backend/tests/unit/test_bridge_majsoul_autoplay.py new file mode 100644 index 0000000..d11fd92 --- /dev/null +++ b/akagi_backend/tests/unit/test_bridge_majsoul_autoplay.py @@ -0,0 +1,88 @@ +from akagi_ng.bridge.majsoul import MajsoulBridge + + +def test_action_prototype_captures_self_operation_list_for_autoplay(): + bridge = MajsoulBridge() + bridge.seat = 1 + + message = { + "method": ".lq.ActionPrototype", + "type": 1, + "data": { + "step": 18, + "name": "ActionDealTile", + "data": { + "seat": 1, + "tile": "5m", + "leftTileCount": 60, + "operation": { + "seat": 1, + "operationList": [ + {"type": 7, "combination": []}, + {"type": 9, "combination": []}, + ], + }, + }, + }, + } + + bridge.parse_liqi(message) + + assert bridge.latest_operation_step == 18 + assert bridge.latest_self_operation_list == [ + {"type": 7, "combination": []}, + {"type": 9, "combination": []}, + ] + + +def test_action_prototype_clears_self_operation_list_after_self_action_without_operation(): + bridge = MajsoulBridge() + bridge.seat = 0 + bridge.latest_self_operation_list = [{"type": 7, "combination": []}] + bridge.latest_operation_step = 11 + + message = { + "method": ".lq.ActionPrototype", + "type": 1, + "data": { + "step": 12, + "name": "ActionDiscardTile", + "data": { + "seat": 0, + "tile": "3m", + "isLiqi": False, + "moqie": False, + }, + }, + } + + bridge.parse_liqi(message) + + assert bridge.latest_self_operation_list == [] + assert bridge.latest_operation_step is None + + +def test_action_prototype_clears_stale_self_operation_list_after_other_player_action(): + bridge = MajsoulBridge() + bridge.seat = 0 + bridge.latest_self_operation_list = [{"type": 0, "combination": []}, {"type": 9, "combination": []}] + bridge.latest_operation_step = 25 + + message = { + "method": ".lq.ActionPrototype", + "type": 1, + "data": { + "step": 26, + "name": "ActionDealTile", + "data": { + "seat": 1, + "tile": "5m", + "leftTileCount": 60, + }, + }, + } + + bridge.parse_liqi(message) + + assert bridge.latest_self_operation_list == [] + assert bridge.latest_operation_step is None diff --git a/akagi_frontend/src/components/SettingsPanel.tsx b/akagi_frontend/src/components/SettingsPanel.tsx index b0035e0..d90d275 100644 --- a/akagi_frontend/src/components/SettingsPanel.tsx +++ b/akagi_frontend/src/components/SettingsPanel.tsx @@ -17,6 +17,7 @@ import { import { StatusBar } from '@/components/ui/status-bar'; import { useSettings } from '@/hooks/useSettings'; +import { AutoplaySection } from './settings/AutoplaySection'; import { ConnectionSection } from './settings/ConnectionSection'; import { GeneralSection } from './settings/GeneralSection'; import { ModelConfigSection } from './settings/ModelConfigSection'; @@ -100,6 +101,8 @@ const SettingsPanel: FC = memo(({ open, onClose }) => { + +