局面表示与规则
引擎不仅要知道“棋子摆在哪里”,还要知道轮到谁走、哪些子正在将军,以及这盘棋怎样走到这里。棋盘、行棋状态和走棋历史共同支撑搜索,单看一张棋盘并不总能判断结果。
本文核对 official Pikafish 提交 1c66b9b,重点解释几个核心文件怎样配合;搜索方法另见搜索算法。
几个文件分别管什么?
| 文件 | 主要职责 |
|---|---|
types.h | 定义棋子、颜色、格点、走法等基础类型。 |
bitboard.h | 提供一组格点的表示及常用位运算。 |
attacks.h/.cpp | 根据位置和阻挡关系,查询棋子的攻击范围。 |
position.h/.cpp | 保存当前局面,处理落子、撤销、合法性与历史规则。 |
movegen.h/.cpp | 结合上述信息,按需生成候选走法或合法走法。 |
这些文件各有分工。例如,攻击表回答“马能到哪些点”,走法生成器把起点和终点组成候选,合法性检查再排除走后露将的着法。基础类型、生成与过滤是相应入口。
同一张棋盘,为什么要存几份信息?
Position 同时保存按格点排列的 board、按棋子种类和颜色划分的位棋盘,以及棋子数量等信息。这不是重复保存几盘棋,而是为不同问题准备方便的查询方式:
- 问“这个点是什么子”,直接查
board。 - 问“全部红车在哪里”,组合颜色与棋子种类的位棋盘。
- 问“攻击范围内有哪些敌子”,把两个格点集合求交集。
位棋盘中的每一位对应一个格点。Pikafish 用 128 位容纳 90 个格点,行、列、半边棋盘和九宫也有相应的集合。车炮的阻挡、马腿和象眼交给攻击规则;生成器不必每次重新编写一套判断。
攻击范围的查表实现及 Magic、PEXT/PDEP 路径,见位棋盘与攻击表。查表加快的是计算方式,并不改变棋子的走法。
落子与撤销:保存的是返回所需的信息
搜索会反复“试走一步—继续计算—退回原处”。如果每次都复制整盘棋并重算所有信息,成本很高。
Pikafish 在当前 Position 上落子,同时建立一个 StateInfo:保存哈希键、计数、将军信息、这步走法和被吃的子等,并用 previous 指向前一个状态。它不是一份完整棋盘副本;部分字段从上一步复制,另一些按新局面更新。
undo_move() 把移动的棋子放回,恢复被吃的子,再让状态指针退回上一项。旧状态中的哈希键、计数等随之恢复。撤销必须恢复整个相关状态,不能只把画面上的棋子挪回去。 否则错误会污染后面搜索的其他分支。
实战已提供的着法和搜索中临时走出的变化,都要保持正确的前后关系。每个节点看到的是自己的历史路径;换到另一个分支,不能沿用刚才分支的将军信息和计数。
Zobrist 哈希:方便认局面,不是完整棋谱
Zobrist 哈希为“某种棋子在某个点”准备一组数,再把棋盘上的这些数和行棋方信息用异或组合成键。走一步时,只需去掉旧位置、加入新位置,并处理吃子和换边,不必重新扫描整盘。
这里能快速更新,是因为同一个数异或两次会抵消:原来棋子所在位置的那项可以直接移除,不需要从结果反推整盘棋。
当前基本键 st->key 是 64 位,主要对应棋子布局与行棋方。它不是绝对无碰撞的编号,也没有把先前每步棋装进去。用于缓存的 key() 还结合限招计数的分组和重复过滤信息作调整,但仍然不能代替完整历史。
因此,“哈希相同”不能被解释为“任何历史下规则结果都相同”。真正判断长将、长捉等情况,还需要沿状态链检查。缓存怎样复用,见置换表。
源码:准备 Zobrist 数表、建立基本键、增量更新、缓存键调整。
能走这一步,和走了会怎样判,是两件事
伪合法候选已经满足棋子的基本走法。legal() 在这个前提下主要检查走后己方将帅是否安全,包括将帅照面;它不会把每条依赖历史的棋规都重新判断一遍。
rule_judge() 则处理重复局面、长将、长捉、自然限招以及特定子力和局等情况。实现先用快速过滤减少无关检查,确有需要时才追查历史、判断捉子。有的搜索中重复情况只提供分数界限,并不立即返回最终胜负。
所以,“这步没有露将”“这条变化触发规则判负”“这条变化搜索分数很差”不能混为一谈。重复也不能简化成一律判和。更详细的说明见规则与搜索。
FEN 是局面记录,不能恢复整段历史
FEN 能记录棋子位置、行棋方和计数信息,但没有逐着记录之前谁将军、谁捉子、局面曾怎样重复。把当前局面导出为 FEN,再单独读回来,并不等于恢复了原先的走棋历史。 当前 set(fen) 会重新初始化局面与起始状态,再由棋盘计算相关信息。
需要复现依赖历史的问题时,应保留一个已知起点及之后的着法序列。UCI 的 position … moves … 正是先设置起点,再逐步走入这些着法,建立对应状态链;它也不能补回起点以前未提供的历史。
向开发者报告规则问题时,最好同时提供起始 FEN、后续着法、引擎版本和预期判定。只发最后一张棋盘,往往不足以重现问题。FEN 解析中的格式与局面检查,也不等于证明该局面一定能从开局合法走到。
源码:FEN 初始化、从起点重放着法;命令形式见 UCI 协议。
perft 能测什么,不能测什么?
perft 按合法走法展开到指定正深度,统计到达该深度的走法序列数。不同顺序即使到达同一盘面,也分别计数。它不挑最佳着法,不靠 NNUE 评分,也不使用搜索剪枝。
当前 perft.h 使用 MoveList<LEGAL>、do_move() 和 undo_move();根节点还分别输出每步的计数,便于定位哪条分支少生成或多生成了走法。
它没有调用 rule_judge(),不能据此宣称验证了全部象棋棋规。 长将、长捉、限招等历史判定,需要另外的测试;最佳着法、评分与选择性搜索也不由 perft 验证。
| 测试 | 主要回答的问题 |
|---|---|
| perft | 走法生成、王安全过滤及落子撤销是否与预期计数一致? |
| 棋规回归用例 | 给定起点与历史后,是否在正确时点作出预期判定? |
| 搜索回归与对弈测试 | 搜索行为是否出现异常,修改是否改善实际表现? |
计数一致是有价值的检查,但不是“引擎所有功能均正确”的证明。
源码:perft 实现。
