TUI 界面(Textual): 棋盘实时渲染+状态面板+快捷键(p暂停 c云库 n换局 q退出); bot 收敛为纯逻辑核心

This commit is contained in:
AwesomeXiangQi committed 2026-09-13 16:50:33 +08:00
1 parent 7c55096a4e
commit ee55ee9c8b
6 files changed
+419 -77

No files matched your search

+158 -74
View File
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
"""Xiangqi.com 在线对弈自动机器人(aiohttp 全异步版)。
"""Xiangqi.com 在线对弈机器人核心(aiohttp 全异步, 协议要点见文末)。
uv run online_bot.py --strength 500
uv run online_bot.py --depth 20 --hash 512 --first-delay 10
协议要点(实测):
协议要点(实测):
- REST 与 WS 均不带 Cookie; WS 握手必须带 Origin, 否则立即被关
- 匹配: POST /api/v2/games/quick-challenge {"config_str":"10/2"}
- 匹配成功后: lobby.left -> g_v2.get
- 匹配成功后: lobby.left -> g_v2.get 入座
- 走子: g_v2.move {game_id, move, thinking_time}; 回执 game.move.success
"""
import argparse
@@ -24,7 +21,6 @@ from .cloud import CloudBook
from .coords import fen_from_moves, site_to_ucci, ucci_to_site
from .pikafish import Pikafish
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
log = logging.getLogger('xiangqi.bot')
API = 'https://api.xiangqi.com/api'
@@ -36,25 +32,133 @@ 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 = aiohttp.ClientSession()
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
self.moves = [] # UCCI
self.thinking = False
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
self._send_lock = asyncio.Lock()
# ---------- 基础 ----------
# ---------- 状态/控制接口(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 ''
@@ -69,43 +173,8 @@ class OnlineBot:
except Exception:
return None
# ---------- 生命周期 ----------
async def run(self):
async with self.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,
ssl=ssl.SSLContext() if False else None, # aiohttp 默认校验; 站点证书正常
autoping=True, heartbeat=20) as ws:
self.ws = ws
log.info('[ws] connected')
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.game_over:
break
async def close(self):
self.game_over = True
await self.cloud.close()
if self.engine:
await self.engine.quit()
await self.session.close()
# ---------- 协议 ----------
async def on_frame(self, raw):
if raw == '2': # 心跳
if raw == '2':
await self.ws.send_str('3')
return
if raw.startswith('0'):
@@ -116,7 +185,7 @@ class OnlineBot:
await self.emit('lobby.join', {'room': 'lobby'})
if not self.args.game:
await asyncio.sleep(1)
asyncio.create_task(self.quick_challenge())
await self.quick_challenge()
return
if not raw.startswith(f'42{NS},'):
return
@@ -132,6 +201,7 @@ class OnlineBot:
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)
@@ -145,33 +215,48 @@ class OnlineBot:
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':
mv = str((data.get('data') or {}).get('move', '')).split()[0]
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':
reason = (data.get('data') or {}).get('end_reason')
res = (data.get('data') or {}).get('end_result') or {}
log.info('[game] 结束: %s (%s)', END_REASONS.get(reason, reason),
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):
async with self.session.post(
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:
@@ -179,23 +264,24 @@ class OnlineBot:
# ---------- 走子 ----------
def is_my_turn(self):
if not self.my_side or self.game_over:
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:
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:
if self.game_over or not self.is_my_turn():
return
fen = fen_from_moves(self.moves)
@@ -204,28 +290,34 @@ class OnlineBot:
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 not self.args.no_cloud:
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()
think_ms = int((asyncio.get_event_loop().time() - t0) * 1000)
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})
@@ -246,18 +338,10 @@ async def async_main(args):
def main():
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='禁用云库')
args = p.parse_args()
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: