# -*- coding: utf-8 -*- """Xiangqi.com 在线对弈机器人核心(aiohttp 全异步, 协议要点见文末)。 协议要点(实测): - REST 与 WS 均不带 Cookie; WS 握手必须带 Origin, 否则立即被关 - 匹配: POST /api/v2/games/quick-challenge {"config_str":"10/2"} - 匹配成功后: lobby.left -> g_v2.get 入座 - 走子: g_v2.move {game_id, move, thinking_time}; 回执 game.move.success """ import argparse import asyncio import base64 import json import logging import re import ssl import aiohttp from .cloud import CloudBook from .coords import fen_from_moves, site_to_ucci, ucci_to_site from .pikafish import Pikafish log = logging.getLogger('xiangqi.bot') API = 'https://api.xiangqi.com/api' WS_URL = 'wss://api.xiangqi.com/socket.io/?jwt={jwt}&EIO=4&transport=websocket' NS = '/xiangqi' FIRST_MOVE_EXTRA = 5 # 红方首步额外延迟秒数 END_REASONS = {100: '绝杀', 101: '困毙', 200: '超时', 300: '认输', 400: '和棋', 401: '不吃子和', 402: '重复和', 500: '断线', 600: '弃局'} def build_parser(): p = argparse.ArgumentParser(description='Xiangqi.com 在线对弈 + 皮卡鱼 + 云库') p.add_argument('--game', help='接管已有对局 ID') p.add_argument('--strength', type=int, default=1000, help='每步思考毫秒') p.add_argument('--depth', type=int, default=None, help='固定深度(优先于 strength)') p.add_argument('--threads', type=int, default=None, help='线程数(默认=CPU核心数)') p.add_argument('--hash', type=int, default=256, help='哈希表 MB') p.add_argument('--config', default='10/2', help='匹配时制 总分钟/加秒') p.add_argument('--first-delay', type=int, default=0, help='红方首步延迟秒(实际=此值+5)') p.add_argument('--move-delay', type=float, default=1.0, help='每步最小间隔秒') p.add_argument('--cloud-min-score', type=int, default=15, help='云库采纳阈值(厘兵)') p.add_argument('--no-cloud', action='store_true', help='禁用云库') p.add_argument('--no-tui', action='store_true', help='不启用 TUI, 仅日志输出') return p class OnlineBot: """对局机器人。状态字段(status/moves/fen/score 等)供 TUI 直接读取。""" def __init__(self, args): self.args = args self.session = None # 惰性: 首个协程调用时创建(需事件循环) self.jwt = None self.engine = None self.cloud = CloudBook(min_score=args.cloud_min_score) self.ws = None self.game_id = None self.my_side = None # 'red' | 'black' self.moves = [] # UCCI self.site_moves = [] # 站点坐标(展示用) self.fen = None self.score = None self.source = None # 'cloud' | 'engine' | 'cloud(n候选)' self.status = '初始化' self.end_reason = None self.moves_count_opponent = 0 self.paused = False self.game_over = False self.game_started = False self.thinking = False self._stop = False self.first_deadline = None self.last_move_at = 0.0 self._ack = 0 # ---------- 状态/控制接口(TUI 用) ---------- @property def use_cloud(self): return not self.args.no_cloud def toggle_cloud(self): self.args.no_cloud = self.use_cloud def toggle_pause(self): self.paused = not self.paused log.info('[bot] %s', '已暂停' if self.paused else '继续') return self.paused async def new_match(self): """弃掉当前对局并重新匹配。""" if self.game_id and not self.game_over: try: await self.emit('g_v2.abandon', {'game_id': self.game_id, 'abandonmentSource': 'timeout'}) except Exception: pass self.game_over = True await asyncio.sleep(1) self.game_over = False self.game_started = False self.game_id = None self.my_side = None self.moves.clear() self.site_moves.clear() self.fen = None self.score = self.source = None self.end_reason = None self.first_deadline = None self.status = '重新匹配中…' await self.quick_challenge() # ---------- 生命周期 ---------- async def _get_session(self): if self.session is None or self.session.closed: self.session = aiohttp.ClientSession() return self.session async def run(self, on_ready=None): session = await self._get_session() async with session.post(f'{API}/users/guest_jwt', timeout=aiohttp.ClientTimeout(10)) as r: r.raise_for_status() self.jwt = (await r.json()).get('jwt') log.info('[auth] guest JWT %s...', self.jwt[:24]) self.engine = await Pikafish( threads=self.args.threads, hash_mb=self.args.hash, movetime=self.args.strength).start() log.info('[engine] threads=%s hash=%sMB', self.engine.threads, self.args.hash) origin = 'https://play.xiangqi.com' async with self.session.ws_connect( WS_URL.format(jwt=self.jwt), origin=origin, autoping=True, heartbeat=20) as ws: self.ws = ws log.info('[ws] connected') self.status = '已连接, 匹配中…' if on_ready: on_ready(self) async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: await self.on_frame(msg.data) elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): break if self._stop: break self.status = '连接断开' async def close(self): self._stop = True self.game_over = True await self.cloud.close() if self.engine: await self.engine.quit() await self.session.close() # ---------- 协议 ---------- async def emit(self, event, data, ack=False): self._ack += 1 n = self._ack if ack else '' payload = json.dumps([event, data], ensure_ascii=False) await self.ws.send_str(f'42{NS},{n}{payload}') def my_name(self): try: payload = self.jwt.split('.')[1] payload += '=' * (4 - len(payload) % 4) return json.loads(base64.urlsafe_b64decode(payload)).get('sub') except Exception: return None async def on_frame(self, raw): if raw == '2': await self.ws.send_str('3') return if raw.startswith('0'): await self.ws.send_str(f'40{NS},' + json.dumps({'token': self.jwt})) return if raw.startswith('40'): log.info('[ws] joined /xiangqi') await self.emit('lobby.join', {'room': 'lobby'}) if not self.args.game: await asyncio.sleep(1) await self.quick_challenge() return if not raw.startswith(f'42{NS},'): return m = re.match(rf'^42{NS},(\d+)?(\[.*\])$', raw, re.DOTALL) if not m: return try: arr = json.loads(m.group(2)) except json.JSONDecodeError: return await self.on_event(arr[0], arr[1] if len(arr) > 1 else {}) async def on_event(self, event, data): if event == 'lobby.my.game.new': self.game_id = data.get('game_id') self.status = f'已匹配 {self.game_id}' log.info('[game] matched %s', self.game_id) await self.emit('lobby.left', {}) await asyncio.sleep(0.3) await self.emit('g_v2.get', {'game_id': self.game_id}, ack=True) elif event == 'game.get.success': d = data.get('data', {}) me = self.my_name() for p, side in ((d.get('rplayer', {}), 'red'), (d.get('bplayer', {}), 'black')): if p.get('username') == me or p.get('identity') == me: self.my_side = side break self.moves = [site_to_ucci(str(x.get('move', x)).split()[0]) for x in d.get('move_history', [])] self.site_moves = [ucci_to_site(m) for m in self.moves] self.fen = d.get('fen') or fen_from_moves(self.moves) self.game_started = True self.status = f'对局中({len(self.moves)} 步)' log.info('[game] %s 我方=%s, 已 %d 步', self.game_id, self.my_side, len(self.moves)) asyncio.create_task(self.maybe_move()) elif event == 'game.start.success': self.game_over = False self.game_started = True self.first_deadline = None asyncio.create_task(self.maybe_move()) elif event == 'game.move.opponent': d = data.get('data') or {} mv = str(d.get('move', '')).split()[0] if d.get('fen'): self.fen = d['fen'] if mv: ucci = site_to_ucci(mv) if not self.moves or self.moves[-1] != ucci: self.moves.append(ucci) self.site_moves.append(mv) self.fen = d.get('fen') or fen_from_moves(self.moves) log.info('[game] 对手 %s (%d 步)', mv, len(self.moves)) asyncio.create_task(self.maybe_move()) elif event == 'game.move.success': self.thinking = False elif event == 'game.server.end': d = data.get('data') or {} self.end_reason = d.get('end_reason') res = d.get('end_result') or {} self.status = f'对局结束: {END_REASONS.get(self.end_reason, self.end_reason)}' log.info('[game] 结束: %s (%s)', self.status, json.dumps(res, ensure_ascii=False)[:120]) self.game_over = True self.thinking = False elif event == 'game.error': log.warning('[game.error] %s', json.dumps(data, ensure_ascii=False)[:200]) async def quick_challenge(self): self.status = '匹配中…' session = await self._get_session() async with session.post( f'{API}/v2/games/quick-challenge', headers={'Authorization': f'Bearer {self.jwt}'}, json={'config_str': self.args.config}) as r: log.info('[match] %s %s', r.status, (await r.text())[:80]) # ---------- 走子 ---------- def is_my_turn(self): if not self.my_side or self.game_over or self.paused: return False return (len(self.moves) % 2 == 0) == (self.my_side == 'red') async def maybe_move(self): if (self.thinking or self.game_over or not self.game_id or not self.my_side or self.paused): return if not self.is_my_turn(): return self.thinking = True try: if not self.moves and self.my_side == 'red' and self.args.first_delay: wait = self.args.first_delay + FIRST_MOVE_EXTRA self.status = f'红方首步延迟 {wait}s' log.info('[game] 红方首步延迟 %ds', wait) await asyncio.sleep(wait) if self.game_over or not self.is_my_turn(): return fen = fen_from_moves(self.moves) t0 = asyncio.get_event_loop().time() # 每步最小间隔(深度/云库秒回时保持节奏) if self.args.move_delay: gap = self.args.move_delay - (t0 - self.last_move_at) if gap > 0: self.status = f'行棋节奏延迟 {gap:.0f}s' log.info('[game] 行棋节奏延迟 %.1fs', gap) await asyncio.sleep(gap) if self.game_over or not self.is_my_turn(): return mv = score = None source = 'engine' if self.use_cloud: cloud = await self.cloud.best(fen, moves_count=len(self.moves)) if cloud: mv, score, source = cloud[0], f'cp {cloud[1]}', f'cloud({cloud[2]})' if mv is None: self.status = '皮卡鱼思考中…' mv, score = await self.engine.bestmove( moves=self.moves, fen=fen, movetime=self.args.strength, depth=self.args.depth) site_mv = ucci_to_site(mv) self.moves.append(mv) self.site_moves.append(site_mv) self.fen = fen_from_moves(self.moves) self.last_move_at = asyncio.get_event_loop().time() self.score, self.source = score, source self.status = f'已走 {site_mv}' think_ms = int((self.last_move_at - t0) * 1000) log.info('[game] %s: %s → %s %s %dms', source, mv, site_mv, score, think_ms) await self.emit('g_v2.move', {'game_id': self.game_id, 'move': site_mv, 'thinking_time': think_ms}) except Exception as e: log.error('[move] %s: %s', type(e).__name__, e) if self.engine._proc.returncode is not None: await self.engine.restart() finally: self.thinking = False async def async_main(args): bot = OnlineBot(args) try: await bot.run() finally: await bot.close() def main(): args = build_parser().parse_args() if not args.no_tui: from .tui import run_tui raise SystemExit(run_tui(args)) try: asyncio.run(async_main(args)) except KeyboardInterrupt: pass if __name__ == '__main__': main()