重构: aiohttp 全异步 + chessdb 云库 + uv 管理
This commit is contained in:
commit
8d996b8c5f
13 files changed
+1726
No files matched your search
@@ -0,0 +1 @@
|
||||
from xiangqi_bot import coords, cloud, pikafish, server, bot
|
||||
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""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
|
||||
- 走子: 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
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
|
||||
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: '弃局'}
|
||||
|
||||
|
||||
class OnlineBot:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.session = aiohttp.ClientSession()
|
||||
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.game_over = False
|
||||
self.first_deadline = None
|
||||
self._ack = 0
|
||||
self._send_lock = asyncio.Lock()
|
||||
|
||||
# ---------- 基础 ----------
|
||||
def emit(self, event, data, ack=False):
|
||||
self._ack += 1
|
||||
n = self._ack if ack else ''
|
||||
payload = json.dumps([event, data], ensure_ascii=False)
|
||||
return 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 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': # 心跳
|
||||
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')
|
||||
self.emit('lobby.join', {'room': 'lobby'})
|
||||
if not self.args.game:
|
||||
await asyncio.sleep(1)
|
||||
asyncio.create_task(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')
|
||||
log.info('[game] matched %s', self.game_id)
|
||||
self.emit('lobby.left', {})
|
||||
await asyncio.sleep(0.3)
|
||||
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', [])]
|
||||
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.first_deadline = None
|
||||
asyncio.create_task(self.maybe_move())
|
||||
elif event == 'game.move.opponent':
|
||||
mv = str((data.get('data') or {}).get('move', '')).split()[0]
|
||||
if mv:
|
||||
ucci = site_to_ucci(mv)
|
||||
if not self.moves or self.moves[-1] != ucci:
|
||||
self.moves.append(ucci)
|
||||
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),
|
||||
json.dumps(res, ensure_ascii=False)[:120])
|
||||
self.game_over = True
|
||||
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(
|
||||
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:
|
||||
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:
|
||||
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
|
||||
log.info('[game] 红方首步延迟 %ds', wait)
|
||||
await asyncio.sleep(wait)
|
||||
if self.game_over:
|
||||
return
|
||||
|
||||
fen = fen_from_moves(self.moves)
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
mv = score = None
|
||||
source = 'engine'
|
||||
|
||||
# 云库优先(深度模式同样生效)
|
||||
if not self.args.no_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:
|
||||
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)
|
||||
think_ms = int((asyncio.get_event_loop().time() - t0) * 1000)
|
||||
log.info('[game] %s: %s → %s %s %dms', source, mv, site_mv, score, think_ms)
|
||||
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():
|
||||
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('--cloud-min-score', type=int, default=15, help='云库采纳阈值(厘兵)')
|
||||
p.add_argument('--no-cloud', action='store_true', help='禁用云库')
|
||||
args = p.parse_args()
|
||||
try:
|
||||
asyncio.run(async_main(args))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""chessdb.cn 云库客户端(aiohttp 异步, 带缓存)。"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import aiohttp
|
||||
|
||||
log = logging.getLogger('xiangqi.cloud')
|
||||
|
||||
API_URL = 'https://www.chessdb.cn/chessdb.php'
|
||||
CACHE_TTL = 7 * 24 * 3600 # 已评局面基本不变, 缓存一周
|
||||
|
||||
|
||||
class CloudBook:
|
||||
"""chessdb 云库查询: 返回 (move, score_from_mover_perspective) 或 None。"""
|
||||
|
||||
def __init__(self, timeout=2.5, min_score=15, cache_size=4096):
|
||||
self.min_score = min_score # 云库着法采纳的最低分(红方视角, 厘兵)
|
||||
self._cache = {} # fen -> (expire_ts, result|None)
|
||||
self._cache_size = cache_size
|
||||
self._sem = asyncio.Semaphore(2) # 礼貌并发限制
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
self._session = None
|
||||
self.ok_count = 0
|
||||
self.miss_count = 0
|
||||
|
||||
async def _get_session(self):
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def query_all(self, fen):
|
||||
"""返回 [(move, score_red_perspective)];云库无局面/出错返回 []。"""
|
||||
now = time.time()
|
||||
hit = self._cache.get(fen)
|
||||
if hit and hit[0] > now:
|
||||
return hit[1]
|
||||
url = f'{API_URL}?action=queryall&board={urllib.parse.quote(fen)}&learn=1'
|
||||
try:
|
||||
async with self._sem:
|
||||
session = await self._get_session()
|
||||
async with session.get(url) as resp:
|
||||
text = (await resp.text()).strip()
|
||||
except Exception as e:
|
||||
log.warning('cloud request failed: %s', e)
|
||||
return []
|
||||
result = []
|
||||
if text.startswith('move:'):
|
||||
for item in text.split('|'):
|
||||
fields = dict(kv.split(':', 1) for kv in item.split(',') if ':' in kv)
|
||||
try:
|
||||
result.append((fields['move'], int(fields.get('score', '-30000'))))
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
self.ok_count += 1
|
||||
else:
|
||||
# unknown / nobestmove / checkmate / stalemate / invalid board
|
||||
self.miss_count += 1
|
||||
if len(self._cache) >= self._cache_size:
|
||||
self._cache.clear()
|
||||
self._cache[fen] = (now + CACHE_TTL, result)
|
||||
return result
|
||||
|
||||
async def best(self, fen, moves_count=0):
|
||||
"""返回 (move, score_mover, depth_note)。
|
||||
|
||||
云库分数为红方视角; 轮黑时取负。分数不足 min_score 时放弃(交给引擎)。
|
||||
moves_count 用于开局阶段放宽采纳阈值(前 8 回合云库几乎必赢)。
|
||||
"""
|
||||
entries = await self.query_all(fen)
|
||||
if not entries:
|
||||
return None
|
||||
side_is_red = (fen.split()[1] == 'w')
|
||||
sign = 1 if side_is_red else -1
|
||||
scored = [(mv, sign * sc) for mv, sc in entries]
|
||||
scored.sort(key=lambda x: -x[1])
|
||||
mv, sc = scored[0]
|
||||
# 开局阈值低(云库碾压引擎), 中后局要求云库分足够好
|
||||
threshold = self.min_score if moves_count >= 16 else 0
|
||||
if sc < threshold:
|
||||
return None
|
||||
return mv, sc, f'{len(scored)}候选'
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""坐标转换: 站点坐标(行1-10) <-> 引擎 UCCI(行0-9), 以及 FEN 构建。"""
|
||||
import re
|
||||
|
||||
MOVE_RE = re.compile(r'^([a-i])(\d+)([a-i])(\d+)$')
|
||||
|
||||
INITIAL_GRID = (
|
||||
'rnbakabnr'
|
||||
'.........'
|
||||
'.c.....c.'
|
||||
'p.p.p.p.p'
|
||||
'.........'
|
||||
'.........'
|
||||
'P.P.P.P.P'
|
||||
'.C.....C.'
|
||||
'.........'
|
||||
'RNBAKABNR'
|
||||
)
|
||||
|
||||
|
||||
def _sub(m, delta):
|
||||
return f'{m.group(1)}{int(m.group(2)) + delta}{m.group(3)}{int(m.group(4)) + delta}'
|
||||
|
||||
|
||||
def site_to_ucci(m):
|
||||
"""'b3e3'->'b2e2', 'h10g8'->'h9g7' (站点行号 1-10, 引擎行号 0-9)."""
|
||||
return MOVE_RE.sub(lambda x: _sub(x, -1), m)
|
||||
|
||||
|
||||
def ucci_to_site(m):
|
||||
"""'b2e2'->'b3e3', 'h9g7'->'h10g8'."""
|
||||
return MOVE_RE.sub(lambda x: _sub(x, +1), m)
|
||||
|
||||
|
||||
def ucci_to_rc(m):
|
||||
"""UCCI 'b2e2' -> ((row,col),(row,col)); row 0 = 黑方底线(FEN 首行) = 9 - rank."""
|
||||
g = MOVE_RE.match(m)
|
||||
if not g:
|
||||
raise ValueError(f'走法格式错误: {m}')
|
||||
return (9 - int(g.group(2)), ord(g.group(1)) - 97), (9 - int(g.group(4)), ord(g.group(3)) - 97)
|
||||
|
||||
|
||||
def fen_from_moves(moves_ucci):
|
||||
"""从初始局面盲走生成 FEN(不走法合法性校验, 走法来自服务器视为可信)。"""
|
||||
grid = list(INITIAL_GRID)
|
||||
turn = 'w'
|
||||
for m in moves_ucci:
|
||||
(r1, c1), (r2, c2) = ucci_to_rc(m)
|
||||
grid[r2 * 9 + c2] = grid[r1 * 9 + c1]
|
||||
grid[r1 * 9 + c1] = '.'
|
||||
turn = 'b' if turn == 'w' else 'w'
|
||||
rows = []
|
||||
for r in range(10):
|
||||
row, empty = '', 0
|
||||
for c in range(9):
|
||||
ch = grid[r * 9 + c]
|
||||
if ch == '.':
|
||||
empty += 1
|
||||
else:
|
||||
if empty:
|
||||
row += str(empty)
|
||||
empty = 0
|
||||
row += ch
|
||||
if empty:
|
||||
row += str(empty)
|
||||
rows.append(row)
|
||||
return '/'.join(rows) + f' {turn} - - 0 1'
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""皮卡鱼异步封装(asyncio 子进程, 替代旧的同步实现)。"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
ENGINE_DIRS = [
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'Pikafish-2026-09-06'),
|
||||
]
|
||||
NNUE_FILE = 'pikafish.nnue'
|
||||
|
||||
|
||||
def find_engine():
|
||||
"""按当前平台挑选引擎二进制: Windows 优先 .exe, 其次 Linux/Mac。"""
|
||||
import platform
|
||||
system = platform.system() # Windows / Linux / Darwin
|
||||
for base in ENGINE_DIRS:
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
candidates = []
|
||||
for name in sorted(os.listdir(base)):
|
||||
path = os.path.join(base, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
lower = name.lower()
|
||||
if 'pikafish' not in lower or lower.endswith(('.nnue', '.txt', '.md')):
|
||||
continue
|
||||
if system == 'Windows':
|
||||
ok = lower.endswith('.exe') and 'android' not in lower
|
||||
elif system == 'Darwin':
|
||||
ok = 'macos' in lower
|
||||
else:
|
||||
ok = 'linux' in lower and 'android' not in lower
|
||||
if ok:
|
||||
candidates.append(path)
|
||||
if candidates:
|
||||
return candidates[0], base
|
||||
return None, None
|
||||
|
||||
|
||||
class Pikafish:
|
||||
"""异步 UCI 引擎进程。非法局面会导致引擎退出, 调用方负责 restart()。"""
|
||||
|
||||
def __init__(self, threads=None, hash_mb=256, movetime=800, log=None):
|
||||
found, workdir = find_engine()
|
||||
if not found:
|
||||
raise RuntimeError('未找到皮卡鱼引擎二进制')
|
||||
self.path, self.workdir = found, workdir
|
||||
self.threads = threads or os.cpu_count() or 1
|
||||
self.hash_mb = hash_mb
|
||||
self.movetime = movetime
|
||||
self.log = log or (lambda m: None)
|
||||
self._proc = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start(self):
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
self.path, cwd=self.workdir,
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL)
|
||||
await self._send('uci')
|
||||
await self._wait_for('uciok')
|
||||
nnue = os.path.join(self.workdir, NNUE_FILE)
|
||||
opts = [('Threads', str(self.threads)), ('Hash', str(self.hash_mb))]
|
||||
if os.path.isfile(nnue):
|
||||
opts.append(('EvalFile', NNUE_FILE))
|
||||
for k, v in opts:
|
||||
await self._send(f'setoption name {k} value {v}')
|
||||
await self._send('isready')
|
||||
await self._wait_for('readyok')
|
||||
await self._send('ucinewgame')
|
||||
self.log(f'引擎已启动: {os.path.basename(self.path)} '
|
||||
f'(threads={self.threads}, hash={self.hash_mb}MB)')
|
||||
return self
|
||||
|
||||
async def restart(self):
|
||||
await self.quit()
|
||||
await self.start()
|
||||
|
||||
async def _send(self, cmd):
|
||||
self._proc.stdin.write((cmd + '\n').encode())
|
||||
await self._proc.stdin.drain()
|
||||
|
||||
async def _readline(self):
|
||||
line = await self._proc.stdout.readline()
|
||||
if not line:
|
||||
raise RuntimeError('引擎进程已退出')
|
||||
text = line.decode(errors='replace').strip()
|
||||
self.log('<< ' + text)
|
||||
return text
|
||||
|
||||
async def _wait_for(self, token):
|
||||
while True:
|
||||
if token in await self._readline():
|
||||
return
|
||||
|
||||
async def bestmove(self, moves=None, fen=None, movetime=None, depth=None):
|
||||
"""返回 (move, score)。fen 优先于 moves; depth 优先于 movetime。"""
|
||||
async with self._lock:
|
||||
if fen:
|
||||
await self._send(f'position fen {fen}')
|
||||
else:
|
||||
pos = 'position startpos'
|
||||
if moves:
|
||||
pos += ' moves ' + ' '.join(moves)
|
||||
await self._send(pos)
|
||||
if depth:
|
||||
await self._send(f'go depth {depth}')
|
||||
else:
|
||||
await self._send(f'go movetime {movetime or self.movetime}')
|
||||
score = None
|
||||
while True:
|
||||
line = await self._readline()
|
||||
if line.startswith('info') and ' pv ' in line:
|
||||
i = line.find('score ')
|
||||
if i >= 0:
|
||||
parts = line[i:].split()
|
||||
if len(parts) >= 3:
|
||||
score = f'{parts[1]} {parts[2]}'
|
||||
if line.startswith('bestmove'):
|
||||
mv = line.split()[1]
|
||||
if mv in ('(none)', '(null)'):
|
||||
raise ValueError('该局面无可走之子')
|
||||
return mv, score
|
||||
|
||||
async def quit(self):
|
||||
if self._proc and self._proc.returncode is None:
|
||||
try:
|
||||
self._proc.stdin.write(b'quit\n')
|
||||
await self._proc.stdin.drain()
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=5)
|
||||
except Exception:
|
||||
self._proc.kill()
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""本地 HTTP 引擎服务: 云库优先, 皮卡鱼兜底。供油猴脚本调用。"""
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
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 %(name)s %(message)s')
|
||||
log = logging.getLogger('xiangqi.server')
|
||||
|
||||
|
||||
class Service:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.cloud = CloudBook(min_score=args.cloud_min_score)
|
||||
self.engine = None # 惰性启动(首个请求前/启动时)
|
||||
|
||||
async def startup(self, app):
|
||||
self.engine = await Pikafish(
|
||||
threads=self.args.threads, hash_mb=self.args.hash,
|
||||
movetime=self.args.movetime,
|
||||
log=(lambda m: log.debug(m)) if self.args.quiet else (lambda m: log.info(m)),
|
||||
).start()
|
||||
|
||||
async def shutdown(self, app):
|
||||
if self.engine:
|
||||
await self.engine.quit()
|
||||
await self.cloud.close()
|
||||
|
||||
async def bestmove(self, moves_site, fen=None, movetime=None, depth=None):
|
||||
moves_ucci = [site_to_ucci(m) for m in moves_site]
|
||||
fen = fen or fen_from_moves(moves_ucci)
|
||||
|
||||
# 1. 云库优先
|
||||
try:
|
||||
cloud_hit = await self.cloud.best(fen, moves_count=len(moves_ucci))
|
||||
except Exception as e:
|
||||
log.warning('cloud error: %s', e)
|
||||
cloud_hit = None
|
||||
if cloud_hit and not depth:
|
||||
mv, score, note = cloud_hit
|
||||
return {'ok': True, 'source': 'cloud', 'move': ucci_to_site(mv),
|
||||
'score': f'cp {score}', 'note': note, 'ms': 0}
|
||||
|
||||
# 2. 皮卡鱼兜底
|
||||
try:
|
||||
mv, score = await self.engine.bestmove(moves=moves_ucci, fen=fen,
|
||||
movetime=movetime, depth=depth)
|
||||
except Exception:
|
||||
log.exception('engine crashed, restarting')
|
||||
await self.engine.restart()
|
||||
raise
|
||||
return {'ok': True, 'source': 'engine', 'move': ucci_to_site(mv),
|
||||
'score': score, 'note': None}
|
||||
|
||||
|
||||
def make_app(args):
|
||||
svc = Service(args)
|
||||
|
||||
async def health(_):
|
||||
return web.json_response({
|
||||
'ok': True,
|
||||
'engine': svc.engine.path if svc.engine else None,
|
||||
'cloud': {'min_score': svc.cloud.min_score,
|
||||
'hit': svc.cloud.ok_count, 'miss': svc.cloud.miss_count},
|
||||
})
|
||||
|
||||
async def bestmove(request):
|
||||
try:
|
||||
req = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({'ok': False, 'error': 'bad json'}, status=400)
|
||||
moves = req.get('moves', [])
|
||||
if not isinstance(moves, list) or not all(isinstance(m, str) for m in moves):
|
||||
return web.json_response({'ok': False, 'error': 'moves 格式错误'}, status=400)
|
||||
try:
|
||||
out = await svc.bestmove(
|
||||
moves,
|
||||
fen=req.get('fen'),
|
||||
movetime=req.get('movetime'),
|
||||
depth=req.get('depth'),
|
||||
)
|
||||
return web.json_response(out)
|
||||
except Exception as e:
|
||||
return web.json_response({'ok': False, 'error': str(e)}, status=500)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get('/health', health)
|
||||
app.router.add_post('/bestmove', bestmove)
|
||||
app.on_startup.append(svc.startup)
|
||||
app.on_cleanup.append(svc.shutdown)
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='皮卡鱼 + 云库本地服务')
|
||||
p.add_argument('--port', type=int, default=9377)
|
||||
p.add_argument('--movetime', type=int, default=800, help='每步思考毫秒(深度未指定时)')
|
||||
p.add_argument('--depth', type=int, default=None, help='固定深度(优先于 movetime)')
|
||||
p.add_argument('--threads', type=int, default=None, help='线程数(默认=CPU核心数)')
|
||||
p.add_argument('--hash', type=int, default=256, help='哈希表 MB')
|
||||
p.add_argument('--cloud-min-score', type=int, default=15,
|
||||
help='云库着法采纳阈值(厘兵, 红方视角)')
|
||||
p.add_argument('--quiet', action='store_true', help='不打印引擎 info')
|
||||
args = p.parse_args()
|
||||
|
||||
app = make_app(args)
|
||||
mode = f'depth={args.depth}' if args.depth else f'movetime={args.movetime}ms'
|
||||
print(f'[server] http://127.0.0.1:{args.port} ({mode}, '
|
||||
f'threads={args.threads or "max"}, hash={args.hash}MB, '
|
||||
f'cloud_min_score={args.cloud_min_score})', flush=True)
|
||||
web.run_app(app, host='127.0.0.1', port=args.port, print=None)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user