Featured image of post 中国象棋:perft 证明我的规则写对了

中国象棋:perft 证明我的规则写对了

完整规则引擎、六档 AI、局域网联机。用 329 万个局面验证走法生成。

一个完整的中国象棋网页版。

不需要构建步骤,克隆下来 npm start 就能跑。手机、平板、电脑都能连。

但真正让我想做这个项目的,不是"做个象棋",而是——

一个规则引擎怎么写才算"写对了"?


规则这东西,看起来简单

七种棋子,每种走法都耳熟能详。写起来好像没什么难的。

真正难的是那些边界条款:

规则容易漏在哪
帅/将九宫内直走一步;任何导致两将同线且中间无子的着法都非法
仕/士九宫内斜走一步
相/象走「田」字,象眼被堵不能走,不能过河
马走「日」字,马腿被堵则该方向不能走
车直线滑行,遇子即止
炮不吃子时同车;吃子必须隔且只隔一个炮架
兵/卒未过河只能向前;过河后可向前或左右,永不后退

还有胜负判定层:

  • 将死判负 —— 被将军且无合法着法
  • 困毙判负 —— 没被将军但也没有任何合法着法(中国象棋特有,国际象棋是判和)
  • 长将判负 —— 循环内某方每一步都在将军
  • 长捉判负 —— 循环内某方每步都在追捉同一个非兵非将棋子
  • 重复局面判和 —— 三次重复且不构成长将/长捉
  • 自然限着判和 —— 120 个半回合无吃子

光靠"我觉得应该对了"是写不完的。


perft:一个不给你留面子的检验

国际象棋引擎圈子里有个标准测试叫 perft:从开局开始,逐层统计所有合法着法能生成的节点数。

它有一个残忍的性质:任何一处规则写错,数字立刻对不上,而且不告诉你错在哪。

中国象棋的公认值是这样的。这是我的实测:

深度节点数(公认)结果
144✅
21,920✅
379,666✅
43,290,240✅

到深度 4,要生成并验证 329 万个局面。

这比"手动走几步看起来没问题"强得多——它逼你把每一个边界条款都写对,因为只要有一处错,329 万会变成 328 万或者别的数。

一个能被数字否证的测试,比一百次"看起来没问题"更有价值。

另外还有 69 项单元测试,逐条覆盖蹩马腿、塞象眼、炮翻山、将帅照面、被牵制棋子、困毙、长将判负、自然限着,以及中文记谱的前后缀消歧。


AI:六档难度,但不许装傻

引擎是纯 JavaScript,跑在 Web Worker 里,所以深度搜索不会卡住界面。

搜索

  • Negamax + Alpha-Beta 负极大值搜索
  • 迭代加深 + 时间控制(随时可以返回当前最优着法)
  • 置换表:Zobrist 校验、深度优先替换、100 万槽位
  • 着法排序:置换表着法 > MVV-LVA 吃子 > 杀手着法 > 历史启发
  • 静态搜索(Quiescence):消除水平线效应
  • 空着裁剪、将军延伸、后期着法削减(LMR)
  • 搜索路径内重复局面按和棋处理,避免引擎自己走进循环

评估

子力价值 + 位置价值表(每种棋子一张 10×9 表)+ 结构项:

  • 机动性(马/车/炮/兵各自可走到的格数)
  • 车的纵线开放性(半开放 / 全开放)
  • 缺士怕双车、残局炮贬值、残局兵升值

开局评估为 0 分(完全对称),速度约 30 万节点/秒。

六档难度:不是简单调深度

难度是四层可调的:搜索深度 + 思考时间 + 选点随机窗口 + 失误概率。

档位深度时间随机窗口失误率
入门20.15s15030%
初级30.3s5512%
中级50.7s183%
高级81.4s50
大师122.8s00
宗师206s00

「随机窗口」=在「最优分 − 窗口」范围内的着法里随机挑一个——这是低难度"像人但会犯错“的关键。

但这里有一条铁律:

无论哪一档,能一步杀就一定会杀。

低难度可以犯错,但不能在该赢的时候装傻。

因为一旦那种事发生,玩家就不认为自己在跟一个对手下棋,而是在跟一个随机数生成器。


联机:服务端说了算

同一份服务端同时提供 HTTP 静态服务与 WebSocket 信令,端口共用一个。

关键是规则引擎浏览器与 Node 共用同一份代码,所以:

  • 服务端为每个房间持有一份 Position
  • 每一步都用规则引擎重新算一遍
  • 非法着法直接回 error,局面不变

改前端无法作弊。

其他细节:

  • 房间码 4 位,字符集去掉易混淆的 I/O/0/1
  • 座位满了自动成为观战者,可观战、可聊天,不能走棋
  • 悔棋需对方同意;对方在你请求后走了子,请求自动作废
  • 断线重连:token 存在 localStorage,120 秒内自动回到座位,超时判负
  • 心跳 25 秒一次,死连接自动清理

一个手机端的真实坑

移动浏览器的音频策略比桌面严格得多。

页面加载时创建 AudioContext 会被自动播放策略永久挂起。

所以这个项目特意不在加载时建上下文,而是在第一次用户触摸时才创建并解锁。

配套做了个音效自检功能:

状态显示含义
运行中 · 音效已开启,却仍听不到设备侧静音(iPhone 检查侧边静音拨片;Android 调媒体音量不是通话音量)
一直显示「已挂起」浏览器还在拦截,再多点几下棋盘
显示「尚未开始」还没产生过触摸

试听按钮始终会发声——这样就能区分"开关关了"和"设备真没声音”。

自动化验证会在严格自动播放策略 + 手机触屏模拟下跑一条完整链路:加载时不建上下文 → 首次触摸即解锁 → 首声不被吞掉 → 落子/AI 应招都有声。


素材:全部可自由使用

  • 棋子汉字字形取自 Wikimedia Commons 的公有领域作品(作者 Inductiveload)
  • 原素材是四格精灵图,只取汉字字形,去掉原图圆片,重新合成为木纹实体棋子(径向渐变 + 车削年轮 + 倒角 + 内圈阳文 + 投影)
  • 棋盘木纹是 SVG feTurbulence 程序化生成的,无第三方版权
  • 音效是 WebAudio 现场合成的,不打包任何音频文件

所以这个项目可以随便分发,没有版权风险。


下一盘(单机版)→

上面是在线单机版。局域网联机需要跑 Node 服务端,本地 npm start 即可。


技术选型

选择原因
原生 JS,无构建步骤克隆下来 npm install && npm start 就能跑
规则引擎浏览器与 Node 共用联机时服务端复用同一份规则做权威校验,避免两套实现不一致
AI 用 Web Worker深度搜索不阻塞界面;拿不到 Worker 时自动退回主线程
棋子用内联 SVG 精灵表32 枚棋子共用一份渐变与滤镜定义,零额外请求,任意缩放清晰
音效用 WebAudio 合成不需要打包音频,落子/吃子/将军音色各不相同
ws 是唯一运行时依赖WebSocket 手写容易在分片、掩码、关闭握手上出错

这个项目让我对"做对"这件事的看法变了一点。

规则能凭记忆写出来,但只有 perft 能证明你写对了。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计