简介:棋类AI通常依赖确定性的局面评估与alpha-beta剪枝,但当棋盘上存在未知暗子时,传统搜索算法会因信息缺失而失效。蒙特卡洛模拟通过随机补全未知信息,用统计平均代替精确计算,为不完全信息博弈提供了可行的求解路径。本文从棋盘编码、走法生成、胜负判定等基础环节出发,逐步构建一个支持暗子翻面的揭棋引擎,并重点讲解单层UCT搜索的实现原理与参数调节方法。针对实际开发中常见的局面哈希失效、将帅判定遗漏、规则版本不统一等关键坑点,给出可直接落地的解决方案。无论是改造象棋引擎还是设计新的博弈AI,这套基于随机采样与自对弈验证的工程思路都值得参考。
1. 揭棋是什么:chiness_chess_jieqi-master 想解决的问题
chiness_chess_jieqi-master 这个项目名拆开看很有意思:chiness_chess 是中国象棋,jieqi 是揭棋,master 通常指项目主分支或“主程序”。揭棋不是普通象棋换皮,而是把所有棋子背面朝上随机摆回原始位置,走一步翻一步,吃到对方将帅直接判胜。也就是说,你面对的是一个“信息不全的棋盘”:对方的马可能蹲在你兵线上,你的炮可能翻出来才发现是象。传统象棋引擎最值钱的开局库、位棋盘、残局库在这里大面积失效,甚至会把搜索带偏。这篇笔记的目标读者是想写揭棋 AI、做象棋引擎改造,或者只是想把一个象棋引擎接到揭棋规则上的人。我按自己实际搭过的方案讲:从 90 格棋盘编码、明暗子走法生成,到单层 UCT 采样搜索,最后列出最容易翻车的五个坑。
2. 揭棋棋局表示:棋盘数组、暗子编码与走法生成
2.1 90 格棋盘上用 16 位值编码明暗子
我一般会把棋盘直接做成一个长度为 90 的整数数组,每格一个 int,0 表示空位。棋子的信息用二进制位拆:第 0 位表示是否已经翻开,第 1 到 3 位表示棋子类型,第 4 位表示红黑方。一个 int 同时记住“这颗子真实是什么”和“现在有没有翻面”,对后续模拟非常关键,因为暗子虽然你看不见,但引擎内部必须知道它的真身,否则吃子后没法做蒙卡补全。
FACE_UP = 1 TYPE_MASK = 0b1110 COLOR_MASK = 0b10000 EMPTY = 0 RED = COLOR_MASK TYPE = ['P', 'H', 'E', 'A', 'K', 'C', 'R'] TYPE_BIT = {t: i << 1 for i, t in enumerate(TYPE)}这段编码里,TYPE_BIT['R']得到的是二进制1100,和TYPE_MASK配合就能取出类型。判断红黑用piece & COLOR_MASK,红方返回 16,黑方返回 0。判断翻没翻用piece & FACE_UP。这种紧凑写法的好处是复制局面时可以直接board[:],搜索里频繁拷贝也不会把对象层级搞得很难受。
初始局面的随机化要注意:红黑双方各 16 枚棋子,只在自己一侧的原始位置上洗牌,不能混到对方半场去。我用的初始位置固定为:底线 9 个位置加炮位 2 个加兵位 5 个,然后分别把红黑各自的 16 个类型随机填入。
ROWS, COLS = 10, 9 RED_BASE = [(9, c) for c in range(COLS)] + [(7, 1), (7, 7)] + [(6, c) for c in range(COLS) if c % 2 == 0] BLACK_BASE = [(0, c) for c in range(COLS)] + [(2, 1), (2, 7)] + [(3, c) for c in range(COLS) if c % 2 == 0] def make_init_board(seed=None): if seed is not None: random.seed(seed) board = [EMPTY] * (ROWS * COLS) def fill(base, color): pieces = ['H','H','E','E','A','A','K','C','C','R','R','P','P','P','P','P'] random.shuffle(pieces) for (r, c), t in zip(base, pieces): board[r * COLS + c] = color | TYPE_BIT[t] fill(RED_BASE, RED) fill(BLACK_BASE, 0) return board这里的关键点是洗牌时所有子都不带FACE_UP,开局全部暗置。seed参数一定要留,不然自对弈无法复现,后面做回归测试时你会想给自己留颗后悔药。
2.2 两类走法生成器:明子按棋规,暗子一步翻
揭棋最容易起分歧的是暗子走法,不同平台规则出入很大。我给自己的实现定的是:暗子只能上下左右走一格,走到空位或对方棋子所在格,移动后立刻翻面;如果落点有对方子,则对方子也被揭晓并吃掉;同时允许原地翻开作为独立动作。这个规则集在本地引擎和自对弈模拟里自洽,接具体平台时再按对方协议开关。
明子走法按普通象棋写,但我默认打开宽松模式:士可出九宫,象可过河,帅可以满棋盘走直线一格。原因很简单,揭棋随机摆子后,士和象很可能出现在九宫外、河界对面,死守传统棋规会让大量棋子成为废子。你如果接的是保守规则平台,把对应判断加回来即可。
def gen_clear_moves(board, r, c, cfg): p = board[r * COLS + c] color = p & COLOR_MASK t = p & TYPE_MASK moves = [] if t == TYPE_BIT['R']: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc = r + dr, c + dc while 0 <= nr < ROWS and 0 <= nc < COLS: target = board[nr * COLS + nc] if target == EMPTY: moves.append((r, c, nr, nc)) else: if (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) break nr += dr; nc += dc elif t == TYPE_BIT['C']: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: jumped = False nr, nc = r + dr, c + dc while 0 <= nr < ROWS and 0 <= nc < COLS: target = board[nr * COLS + nc] if not jumped: if target == EMPTY: moves.append((r, c, nr, nc)) else: jumped = True else: if target != EMPTY: if (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) break nr += dr; nc += dc elif t == TYPE_BIT['H']: for dr, dc in [(-2,-1),(-2,1),(-1,-2),(-1,2),(1,-2),(1,2),(2,-1),(2,1)]: leg_r, leg_c = dr // 2, dc // 2 nr, nc = r + dr, c + dc if not (0 <= nr < ROWS and 0 <= nc < COLS): continue if board[(r + leg_r) * COLS + c + leg_c] != EMPTY: continue target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) elif t == TYPE_BIT['E']: for dr, dc in [(-2,-2),(-2,2),(2,-2),(2,2)]: er, ec = r + dr // 2, c + dc // 2 nr, nc = r + dr, c + dc if not (0 <= nr < ROWS and 0 <= nc < COLS): continue if board[er * COLS + ec] != EMPTY: continue target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) elif t == TYPE_BIT['A']: for dr, dc in [(1,1),(1,-1),(-1,1),(-1,-1)]: nr, nc = r + dr, c + dc if not (0 <= nr < ROWS and 0 <= nc < COLS): continue target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) elif t == TYPE_BIT['K']: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc = r + dr, c + dc if 0 <= nr < ROWS and 0 <= nc < COLS: target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) elif t == TYPE_BIT['P']: fwd = -1 if color == RED else 1 candidates = [(r + fwd, c)] if (color == RED and r <= 4) or (color == 0 and r >= 5): candidates += [(r, c - 1), (r, c + 1)] for nr, nc in candidates: if not (0 <= nr < ROWS and 0 <= nc < COLS): continue target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) return moves这里有一个必须注意的细节:炮的循环里,遇到第一个非空格子后,只是把jumped置为 True,然后继续往后探,第 2 个非空格子才是可吃目标;中间那个空位不能落子,所以jumped分支里目标为 EMPTY 时不会 yield。马腿和象眼检查都是看中间那颗棋子是不是空,这与普通象棋一致。兵过河后的横移判断,红方以r <= 4为准,黑方以r >= 5为准,和数组从上往下排列的行号对应。
暗子走法生成器要简单得多:
def gen_hidden_moves(board, r, c): moves = [(r, c, r, c)] # 原地翻开 color = board[r * COLS + c] & COLOR_MASK for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc = r + dr, c + dc if not (0 <= nr < ROWS and 0 <= nc < COLS): continue target = board[nr * COLS + nc] if target == EMPTY or (target & COLOR_MASK) != color: moves.append((r, c, nr, nc)) return moves暗子的移动统一走一步,不区分车马炮。原地翻开的作用是给引擎一个最低限度的合法动作,避免出现整局卡死,也方便后期调试残局。如果你接的平台不支持原地翻子,把这个动作从列表里去掉就行,不影响搜索逻辑。
2.3 吃子、翻子与胜负判定:先定规则再写代码
执行一步棋时,源格子里的棋子无论明暗,一律在落点里置为已翻开。返回被吃掉的子,这样主循环可以直接判断是不是吃到了将帅。
def apply_move(board, move): r, c, nr, nc = move src, dst = r * COLS + c, nr * COLS + nc piece = board[src] taken = board[dst] board[src] = EMPTY board[dst] = piece | FACE_UP return taken胜负判定上,我用“将帅被吃立即判负”作为唯一硬规则,另外检查是否无合法动作。因为揭棋将帅初始位置随机,很多局面根本不存在“将军”的概念,传统象棋的将帅同线照面、九宫禁手在宽松规则下都可以不写。注意apply_move返回taken之后,如果taken类型是 K,这一步的走子方直接获胜。无合法动作的判定不必单独写一个is_terminal做全局检查,在主循环和模拟函数里,每次gen_all_moves的返回值如果为空,就说明当前方输了。
3. 用 Python 写一个最小揭棋引擎:蒙特卡洛采样与单步决策
3.1 为什么选蒙特卡洛不选 alpha-beta:暗子信息缺失下的搜索
普通象棋引擎的 alpha-beta 剪枝依赖一个前提:博弈树节点上的局面是确定的,评估函数能给出静态价值。揭棋开局后双方各 16 颗暗子,你只知道自己的棋和已经翻开的部分,对方暗地里可能蹲着一颗车。你让 alpha-beta 在某个节点上算出来的“进攻路线”,实际对局里可能因为一颗你没看见的炮而完全不存在。
我试过直接把开源象棋引擎的搜索引擎挪过来用,结果很惨:它会疯狂走那些“看起来能吃子”的路线,实际执行时发现对方暗子根本不是那么回事。换成蒙特卡洛后,一次模拟里把未知棋子随机补全成某个确定局面,然后随机走到底,用大量统计平均消掉单次补全的偏差。这是揭棋这类不完全信息博弈里比较朴素但有效的路线。
| 方法 | 对暗子的处理 | 主要问题 |
|---|---|---|
| alpha-beta + 象棋评估 | 无法直接表示未知子 | 节点局面不确定,剪枝结果失真 |
| 蒙特卡洛 + 随机补全 | 每局模拟前洗牌补全 | 模拟次数不够时统计噪声大 |
| 完整信念状态求解 | 维护对手暗子的概率分布 | 实现复杂,状态空间爆炸 |
3.2 随机补全 + UCT 选步:C 值和模拟次数怎么定
先给一个能跑的最小实现,不是完整 UCT 树,而是“根节点采样 + UCB 公式选步”。每一轮从当前局面的所有合法动作里,用 UCT 公式选一个动作,然后从执行动作后的局面开始随机 playout,把输赢回填到这个动作的统计里。这个做法足够验证规则,也容易扩到多层树。
import random, math, time def opposite(color): return 0 if color == RED else RED def gen_all_moves(board, color): moves = [] for idx, piece in enumerate(board): if piece == EMPTY or (piece & COLOR_MASK) != color: continue r, c = divmod(idx, COLS) if piece & FACE_UP: moves.extend(gen_clear_moves(board, r, c)) else: moves.extend(gen_hidden_moves(board, r, c)) return moves def playout(board, color, max_ply=200): while max_ply > 0: moves = gen_all_moves(board, color) if not moves: return opposite(color) move = random.choice(moves) taken = apply_move(board, move) if taken and (taken & TYPE_MASK) == TYPE_BIT['K']: return color color = opposite(color) max_ply -= 1 return None def select_root_move(stats, parent_visits, c=0.8): unexplored = [m for m, st in stats.items() if st[1] == 0] if unexplored: return random.choice(unexplored) best_move, best_ucb = None, -1e9 for move, (wins, visits) in stats.items(): exploit = wins / visits explore = c * math.sqrt(math.log(max(1, parent_visits)) / visits) ucb = exploit + explore if ucb > best_ucb: best_ucb = ucb best_move = move return best_move def uct_search(board, color, sims=300, c=0.8): moves = gen_all_moves(board, color) if not moves: return None, {} stats = {move: [0, 0] for move in moves} for i in range(1, sims + 1): move = select_root_move(stats, i, c) sim_board = board[:] taken = apply_move(sim_board, move) winner = color if not (taken and (taken & TYPE_MASK) == TYPE_BIT['K']): winner = playout(sim_board, opposite(color)) if winner == color: stats[move][0] += 1 stats[move][1] += 1 best = max(moves, key=lambda m: stats[m][0] / max(1, stats[m][1])) return best, statsselect_root_move里 c 是探索常数,我常用 0.8。c 越大越偏向探索冷门动作,越小越偏向选当前胜率高的动作。模拟次数sims建议先在开发机上用 50 跑通,再按时间预算提到 300 到 500。playout里max_ply设 200 是防止随机模拟陷入无限循环,毕竟揭棋里棋子少时会绕圈。这个方法本质上是单层蒙特卡洛,真正的 UCT 树需要在每次模拟后把子节点统计一起回传,等规则验证没问题后再扩。
3.3 连起来跑一盘:主循环与落子日志
有了搜索函数,自对弈主循环就很好写了。每步先生成合法动作,如果没有直接判负;有则让uct_search返回这一步,执行后用apply_move的返回值判断是否吃到了将帅。
def self_play(sims=100, seed=1): board = make_init_board(seed) turn = RED log = [] while True: moves = gen_all_moves(board, turn) if not moves: log.append(("black_win" if turn == RED else "red_win")) break move, stats = uct_search(board, turn, sims=sims) if move is None: log.append(("black_win" if turn == RED else "red_win")) break taken = apply_move(board, move) r, c, nr, nc = move log.append((turn, (r, c), (nr, nc), taken)) if taken and (taken & TYPE_MASK) == TYPE_BIT['K']: log.append(("red_win" if turn == RED else "black_win")) break turn = opposite(turn) return log print("对战结束:", self_play(sims=50, seed=7)[-1])我一般会让log里记录走子方、起止格子和被吃子类型。调试时最有用的是被吃子那一列,能看出引擎是不是在开盲盒似的乱吃:如果它频繁吃出士象但不去追杀对方将帅,说明搜索深度或者随机补全的分配逻辑有问题。日志保存成文本后,我还会自己写个十行左右的统计脚本,统计每盘棋平均步数、吃子数、胜负方,这些指标比单盘棋更有说服力。
另外,主循环里每次uct_search都要重新生成一次合法动作,搜索内部还会反复生成,这个重复在 debug 阶段无所谓,真要提高速度就把gen_all_moves的结果传到搜索函数里缓存。纯 Python 版在开发机上 sims 开到 300 之后,每步耗时可能到一两秒,自对弈一局会比较久,这是正常的,别急着上 2000 sims。
4. 揭棋引擎最容易翻车的五个坑:哈希、将帅判定与配置对齐
4.1 暗子翻面改变局面哈希
现象:搜索跑到一半,缓存里的节点反复失效,内存涨得飞快。
原因:很多改造象棋引擎的人直接把整盘棋盘当作哈希键,翻面前后同一个位置的值从暗子变成了明子,哈希完全变了,导致搜索树无法复用。普通象棋不存在这个问题,因为所有子都是明的。
解决:做局面缓存时,对暗子把FACE_UP位遮掉再参与哈希,或者只对“已公开信息”做键。对局者视角里,暗子翻没翻是真正的信息变化,但引擎内部做模拟补全时,这个位不该成为缓存暴增的元凶。
def board_key(board): key = 0 for piece in board: if piece == EMPTY: key = key * 131 + 0 else: key = key * 131 + (piece & ~FACE_UP) return key这样写之后,同一颗子翻面前后的局面在“内部模拟缓存”里是同一个键。
4.2 吃将判负要放在走子分支里检查
现象:自对弈日志里出现“红方已经吃掉黑将,棋局又继续走了十几步”的诡异记录。
原因:主循环只在每次走子前检查棋盘上还有没有双方将帅,忽略了apply_move在吃子瞬间就应该触发终局。比如红车吃黑将后,棋盘上黑 K 确实没了,但循环里把这一步当成普通吃子处理。
解决:每次apply_move之后立刻检查返回值,K 被吃掉马上退出循环。playout 里也一样,随机模拟走到吃将那一步就得停,否则后续动作全是无效统计。
taken = apply_move(board, move) if taken and (taken & TYPE_MASK) == TYPE_BIT['K']: # 当前走子方直接获胜,立即结束 break4.3 随机补全不能直接用当前 board
现象:本地自对弈正常,一接到真人对战平台,引擎胜率暴跌。
原因:很多揭棋程序写到最后,搜索用的棋盘一直是引擎内部的“全知棋盘”,所有暗子的真实类型都写在 int 里。对平台对局来说,这是一眼看到对手底牌,等于作弊视角下还赢不了。
解决:对平台接口做蒙卡模拟前,先维护一个“公开棋盘”,只包含你自己翻过的子和你翻出的对方子。每次模拟开始前,把未知格子里缺失的子力随机洗牌补全,再交给 playout。本地单机调试可以继续用全知棋盘,但接口要留一个开关。这个开关也是你判断引擎棋力的试金石:全知版和半知版的胜率差距如果太大,说明你的搜索过度依赖偷看暗子。
4.4 士象规则版本太杂,先对齐平台
现象:同样的引擎,换个对弈平台就报一堆非法走法,或者出现走法合法但对方平台判定为违规。
原因:揭棋规则各平台不统一,光暗子走法就有“一步格”“任意直线”“必须原地翻开”好几种,士象将帅能不能出宫过河更是每个平台自己的配置。
解决:把规则集中放成一个配置文件,每个布尔开关对应一种变体。
CFG = { "loose_advisor": True, # 士可出九宫 "loose_elephant": True, # 象可过河 "loose_king": True, # 帅/将无九宫限制 "hidden_move_once": True, # 暗子只能走一格 }接入平台时先拿平台规则文档逐条过一遍开关,没有文档的就按对方已有的对局回放反推规则。别凭感觉默认“揭棋都一样”,这是最容易白忙一场的地方。
4.5 时间参数应该在真实对局里标定
现象:本地设了 sims 500,开发机上算一手要 3 秒,对局平台超时判负;反过来设 sims 20,速度快了但胜率明显低于直觉。
原因:自对弈调试时你等得起,线上对局等不起。模拟次数和耗时不是线性关系,playout 长度、合法动作数都会拉长单次模拟耗时。
解决:搜索入口先用时间预算而不是固定 sims。
def uct_search_time(board, color, budget=0.5, c=0.8): moves = gen_all_moves(board, color) stats = {m: [0, 0] for m in moves} start = time.time() turns = 0 while time.time() - start < budget: move = select_root_move(stats, turns, c) sim_board = board[:] taken = apply_move(sim_board, move) winner = color if not (taken and (taken & TYPE_MASK) == TYPE_BIT['K']): winner = playout(sim_board, opposite(color)) if winner == color: stats[move][0] += 1 stats[move][1] += 1 turns += 1 best = max(moves, key=lambda m: stats[m][0] / max(1, stats[m][1])) return best, turns先跑一段标杆数据:在目标机器上测出 100 次模拟的平均耗时,再换算成时间预算。我习惯开局阶段给 0.5 秒,残局阶段减到 0.3 秒,因为残局合法动作少,同样时间能算更多层。
5. 验证与调优:从自对弈胜率到信息泄漏检查
验证一个揭棋引擎有没有棋力,最直接的方法是让它和基线策略打循环赛。我常用三个基线:纯随机走、贪心只吃子、固定模拟次数 UCT。先跑 30 局,看胜率分布而不是单局表现。
def run_match(policy_a, policy_b, games=30, sims=80): wins = {"A": 0, "B": 0, "draw": 0} for g in range(games): board = make_init_board(seed=g) turn = RED while True: moves = gen_all_moves(board, turn) if not moves: wins["B" if turn == RED else "A"] += 1 break if turn == RED: move = policy_a(board, turn, sims) else: move = policy_b(board, turn, sims) taken = apply_move(board, move) if taken and (taken & TYPE_MASK) == TYPE_BIT['K']: wins["A" if turn == RED else "B"] += 1 break turn = opposite(turn) return wins如果 UCT 连贪心吃子都打不赢,先别急着加大 sims,回头查走法生成里暗子吃子是不是漏掉了翻面逻辑。我遇到过引擎一直不敢暗子吃子,因为它认为暗子移动后应该还是暗的,结果导致搜索永远避开吃子路线。这类问题在自对弈日志里非常显眼,吃子数长期为 0 就是信号。
第二步做信息泄漏检查:把搜索里的全知棋盘换成“公开棋盘 + 随机补全”版本,同参数打 20 局。如果全知版胜率明显高于半知版,说明搜索大量依赖偷看暗子,这在模拟调试里没毛病,但上平台就是另一个故事。我会把两个版本的差距控制在 5% 以内才敢接真人对战。
最后,我会固定一颗随机种子,构造几个手工残局:比如红方已翻出一颗车,黑方只剩一颗暗将,看引擎能不能在 30 步内追杀。揭棋规则下将帅可能在任何位置,这个残局比普通象棋的残局更能暴露搜索短视问题。蒙卡搜索在窄局面里经常出现来回绕圈,原因是随机 playout 里双方乱走,胜率信号被稀释,这时可以临时把 playout 里的随机走法改成“优先吃大子”的启发式,胜率会立刻改善。但启发式会引入评估偏好,改完记得重新跑一遍循环赛。
我自己在这类项目上最大的教训是:先做日志和基线,再调参数,别一上来就堆模拟次数。揭棋的搜索黑匣子感比象棋强得多,没有基线对比,你连“变好了”还是“变坏了”都说不清。希望帮到你。
本文还有配套的精品资源,点击获取