141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
# -*- 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,
|
|
req_cloud=None, cloud_always=None):
|
|
moves_ucci = [site_to_ucci(m) for m in moves_site]
|
|
fen = fen or fen_from_moves(moves_ucci)
|
|
|
|
# 1. 云库优先(请求可显式关闭; 全程云库模式无视分数门槛和深度强制)
|
|
use_cloud = req_cloud if req_cloud is not None else True
|
|
if use_cloud:
|
|
try:
|
|
cloud_hit = await self.cloud.best(
|
|
fen, moves_count=len(moves_ucci),
|
|
any_score=bool(cloud_always))
|
|
except Exception as e:
|
|
log.warning('cloud error: %s', e)
|
|
cloud_hit = None
|
|
if cloud_hit:
|
|
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}
|
|
|
|
|
|
@web.middleware
|
|
async def cors_middleware(request, handler):
|
|
"""油猴脚本从 https 页面跨域调用本服务: 处理 OPTIONS 预检并补 CORS 头。"""
|
|
if request.method == 'OPTIONS':
|
|
resp = web.Response(status=204)
|
|
else:
|
|
resp = await handler(request)
|
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
|
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
|
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
|
return resp
|
|
|
|
|
|
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'),
|
|
req_cloud=req.get('cloud'),
|
|
cloud_always=req.get('cloud_always'),
|
|
)
|
|
return web.json_response(out)
|
|
except Exception as e:
|
|
return web.json_response({'ok': False, 'error': str(e)}, status=500)
|
|
|
|
app = web.Application(middlewares=[cors_middleware])
|
|
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()
|