简而言之

  • Shogi4 是一款 4×4 的带打入将棋,它的规则已从公开网络上消失。我通过反编译官方安卓应用还原了这些规则。
  • 我搭建并验证了一个求解器,完整求解了一个含 21 亿局面的封闭子博弈:双方各一枚王,外加其余每种棋子各一枚。
  • 由于打入会把子留在盘面上,完整博弈是一张 4×10¹⁴ 个槽位的表:约 50 TB,130 到 190 核心年,折合云计算 10,000 到 15,000 美元。
  • 分布式设计已在封闭博弈上完成测试,但未真正跑起来。完整求解只会为一款几乎没人玩的游戏确认一个数字。

Shogi4 是 Oca Studios 发布的一款公有领域 4×4 带打入将棋,其规则已从公开网络上消失。我从官方安卓安装包中还原了规则,随后围绕还原出的规则构建了一个强解引擎。

这并不是一次完整求解。它是一个经过验证的求解器、一个含 21 亿局面的封闭子博弈,以及一套在封闭博弈上测试过、但从未全规模运行的分片外存设计。有意思的恰恰是这条边界:Shogi4 是动物将棋之上的第一级台阶,在这里打入把一次博弈求解变成了分布式系统问题。

可以直接在下面试试规则。查看器会强制执行合法着法,包括打入、升变和越友跳,但不会显示理论结果。

在新窗口打开 →

本文余下部分把两件事分开讲:完整博弈的求解器设计(已测试但未全规模运行),以及最大的封闭子博弈(已实际求解)。

规则

没有任何二手资料以引擎级精度记录 Shogi4 的规则。可打印自玩的 PDF 从未被存档,出版方的服务器也已下线。我从官方安卓安装包中还原了规则:这是一个 Python/Kivy 应用,游戏代码以一个 .mp3 文件的形式发布,实际上是一个 gzip 压缩的字节码 tar 包。反编译后,其中的 get_possible_squares 和 can_take_square 函数就是权威规则集。完整还原的规则见 Mistboard;这里的摘要足够看懂求解过程。

游戏在 4×4 棋盘上进行,每方五枚棋子,全部每回合走一格:

4×4 棋盘上的 Shogi4 初始局面,先行方的鹤、狐、狸、貘、鲤朝上,后行方的棋子朝下 初始局面。棋子归属由朝向表示,而非颜色。

棋子 走法
王将(鹤 / 雉) 一格,8 个方向任意(相当于王)
鲤 向前一格
狐 正向一格
狸 斜向一格
貘 向前或斜前一格

有三条规则让 Shogi4 需要自己的引擎,而不是把动物将棋改改就行:

  • 打入。被吃的棋子会改变归属,从手中放回任意空格,因此子力总量守恒。唯一的限制是不能打入对方底线。
  • 升变。走到最远一行的棋子必须升变:鲤、貘、狸升为银将走法,狐升为金将。被吃掉的已升变棋子恢复为原始形态。
  • 越友跳。如果在某个合法走子方向的相邻格上有一枚己方棋子,该棋子可以跳过它落到两格外。只能跳一枚己方棋子,不能连跳,也不能跳过敌方棋子。动物将棋中没有对应规则。

吃掉王将即获胜。没有将军或将死的概念,走到会被吃的位置也是合法的,也没有王入敌阵取胜的规则。与动物将棋相比,多出一枚棋子、更大的棋盘、更宽的升变集合、打入限制以及越友跳规则,足以要求一个全新的引擎。

为什么完整的 Shogi4 是一个分布式问题

强解通过逆向分析为每个局面赋值(胜、负或和棋),从终局局面(这里是王被吃掉)往回推。若某个着法可到达一个负局面,则该局面为胜;若所有着法都到达胜局面,则为负;否则为和棋。

在国际象棋中,吃子只会减少子力,因此求解可以分解成一层层越来越小的残局,自下而上求解。打入破坏了这种结构。被吃的子会回来,子力固定,整张图是一个强连通的循环,必须作为一个整体求解。这个整体很大:

量 数值
全排列上界(精确值) 205,148,532,253,680
从初始局面可达 ~3×10¹³
稠密编号索引域 N 410,297,064,507,360

求解器用稠密编号(局面到整数的双射)为局面建立索引,于是值表就是一个平坦数组,查表是 O(1)。索引必须覆盖每一种合法排列,因此决定规模的是 N(约 4×10¹⁴),而不是可达局面数:每槽两比特约为 100 TB,做精确的左右对称折叠后约 50 TB。这使得完整求解是一次分布式外存计算,而不是内存中就能跑完的任务。

完整求解的设计

求解器采用推送式。每个局面保存一个未定子节点计数。终局局面(王在下一着会被吃)被初始化为胜,随后值向后传播:只要某个局面有一着走进负局面,它立刻成为胜;而某个局面的计数降到零,意味着它所有子节点都已是胜,它就成为负。值只会从已定流向未定,因此整个计算是一个单调不动点。

为了扩展到多台机器,编号空间被划分成若干分片,每个分片持有自己槽位的值和计数。确定一个局面的值时,通过反着法生成其前驱,并把每个前驱作为消息发给持有它的分片。这种路由就是 shuffle,也是工作量的主体:每个局面约 7 条前驱消息,全规模下为 15 到 30 PB。

分片逆向求解器的一个超步:已定值生成前驱,消息按稠密编号 shuffle,各分片在磁盘上更新值数组和计数数组,然后由屏障开启下一轮 每个分片持有稠密编号表的一段。消息把计算搬到持有该前驱的分片上。

工作节点在屏障后按超步推进。每个节点处理完当前一轮消息,确定能确定的值,并发出下一轮消息。由于不动点是单调的,最终的表既不依赖空间如何分片,也不依赖消息到达的顺序。分片只改变调度,绝不改变答案,正是这一性质让该计算可以安全地分布式化。

决定计算形态的是存储,而不是算力。值数组分片存放在磁盘上,按超步流式读取后丢弃,而不是常驻内存;精确的左右折叠把存储和工作量都减半。

验证

引擎与一个独立的 Python 移植版(基于反编译的应用)对照检查。索引和值分别验证。

着法生成。perft(从初始局面起各深度的着法树节点数)逐位吻合:

深度 局面数
1 8
2 64
3 626
4 6,304
5 68,723
6 769,014

另有一项覆盖 4,000 个局面的随机对局差分测试(含打入、升变、吃子、跳跃),两个引擎的着法列表零不一致。

索引。稠密编号是经过验证的双射:在每个小型博弈的索引上,编号再反编号都是恒等映射,且它枚举出的局面与直接枚举一致。它在完整博弈上的域恰好是独立排列计数的两倍。该枚举器还以动物将棋作为校验:它复现了 Tanaka 公布的 1,567,925,964 个局面数,以及按手中棋子的完整分解。

值。三个求解器(两个 Python 算法、一个 Rust)在每个封闭子博弈上结果一致。局部一致性审计零违例:每个胜局面都有一着通向负,每个负局面的所有着法都通向胜,和棋局面两者皆无。左右镜像完全成立,零不一致,前驱生成器与真正的反向边吻合。

这些已完整求解的小型封闭博弈,是 Shogi4 的首批博弈论结果(值以行棋方视角给出):

子博弈 局面数 胜 负 和棋
2 王 480 35.0% 0% 65.0%
2 王 + 鲤 24,480 75.9% 23.3% 0.7%
2 王 + 狐 24,480 76.7% 23.3% 0%
2 王 + 狸 24,480 76.2% 23.8% 0%
2 王 + 鲤 + 狐 1,164,704 74.5% 17.8% 7.7%

双王结果没有负局面,这是一个合理性检验:只剩王时,行棋方总能避开被迫吃子。

在笔记本上为完整求解设计去风险

在租用集群之前,我在一台笔记本上测试了分布式设计,以内存求解器作为参照标准。

  • 汇合性。分片求解器在 1、4、16、64 个分片下都复现了单机结果。传播最多跑 54 个超步,即最长强制变化的深度。单调不动点的论证说明分片数不可能有影响;测试证实了这一点。
  • 校验。一次一致性遍历就能以低于求解本身的成本认证整张表。在 {2 王 + 鲤 + 狐} 上,求解耗时 5.5 秒,审计 3.0 秒,即校验占求解的 55%,而我人为破坏的每个值都被抓出(每个表现为 1 到 5 处局部违例)。
  • 恢复。崩溃重放能逐字节复现每个超步。实现它时抓到了一个真实缺陷:前驱是从哈希集合中收集的,导致重放顺序与原始顺序不一致。对输出排序后求解器变为确定性的,这正是幂等恢复所需要的。

最大的一次封闭运行

设计验证完成后,我求解了一个更大的封闭博弈:双方各一枚王,加上每种棋子各一枚(鲤、狐、狸、貘),共 2,100,849,024 个局面。它是一个简化博弈,而不是完整 Shogi4 的切片,因为打入使子力守恒,真实博弈永远不会到达每种棋子各一枚的配置。它是我能用来在较大规模上验证核心的最大封闭博弈。

   
实例 Hetzner Cloud CCX43(16 个独享 vCPU,64 GB)
操作系统 Ubuntu 26.04
模式 单线程,全程在内存中
峰值内存 约 15 GB(工作队列压缩为 32 位编号)
实际耗时 5.3 小时
成本 约 2 美元

这是一次单机内存求解,不是分布式路径的运行。约 15 GB 的占用在笔记本上也放得下;我只是租了台机器,好让自己的机器在这五个小时里空出来。结果(以行棋方视角):

结果 局面数 占比
胜 1,662,776,212 79.15%
负 339,566,116 16.16%
和棋 98,506,696 4.69%

各项计数之和与局面总数完全相符,并通过了审计。

这次运行还校准了单边成本随工作集大小的变化:

局面数 每边纳秒数
1,164,704 327
51,461,568 493
2,100,849,024 697

21 亿这一档是第一次让值数组(约 14 GB)超出 CPU 缓存的运行,因此 697 ns/边是推算完整求解的依据。缓存内的基准测试会低估这类任务。

完整求解要花多少钱

以 697 ns/边为锚点、4×10¹⁴ 为索引规模,完整求解大约需要 50 到 100 TB 的工作存储,以及 130 到 190 核心年的算力:约一百万核心小时,按云价折合 10,000 到 15,000 美元。

这个数字来自两次修正。原始局面数起初差了一个数量级。随后又把占用错按可达局面数(约 3×10¹³)而不是排列域(约 4×10¹⁴,大约是前者的 13 倍)估算,而稠密编号索引必须覆盖后者。

这个索引是别扭但必要的部分。小型求解器可以枚举可达局面,并维护一个从局面键到表槽位的 HashMap。在这个规模下,构建这张映射本身就已经太大。可扩展的做法是给每一种排列一个数学地址:rank(position) -> integer。这会在非法或不可达的排列上浪费槽位,但它把残局库变成了可以分片、流式处理、检查点和审计的平坦数组。

同样的取舍也出现在逆向步骤中。小型内存求解器可以存一份压缩稀疏行(CSR)形式的全部前驱边列表,然后直接遍历这张反向图。完整求解器负担不起为每条边存一行。它改为按需生成前驱:还原着法形态,为每个前驱计算编号,再把更新发给持有该编号的分片。这样每条边的 CPU 开销更高,但这正是内存受限的捷径与外存求解器之间的差别。

在得出这一估算之后,我在相邻的 4×5 微型将棋台阶上实测了这一取舍:每方 K+P+G,869,287,068 个可达规范局面。可达 HashMap + 存储 CSR 的求解器核心求解峰值 RSS 约 60.5 GiB、耗时 12,385 秒。而针对 KPG 的稠密编号 + 反着法求解器使用了 2,037,557,340 个编号槽(是可达数的 2.34 倍),复现了相同的值计数,峰值 RSS 约 6.86 GiB,总求解时间 8,464 秒。

方法 索引 前驱 峰值内存 单核耗时
可达 + CSR 8.69 亿个可达键 存储反向图 约 60.5 GiB 核心求解 12,385 秒
稠密编号 + 反着法 20.4 亿个编号槽 按需生成 约 6.86 GiB 总求解 8,464 秒

这就是工程上的取舍:稠密编号用槽位和编号/反编号的 CPU 开销,换取不必存储反向图。在这一档上,内存下降了 8.8 倍,单核运行快了 1.46 倍,因为它免去了构建和保存反向图。

为什么没有跑

引擎、分布式设计、校验和标定都已完成。要完成完整求解,需要的是租来的算力,而不是又一个想法。

这笔花费过不了门槛。技术是已知的,所以结果里没有论文;游戏很冷门,所以它的值也没什么需求。自掏腰包只会买来一个数字:一款几乎没人玩的游戏的博弈论值。

这个项目的价值在于能力本身。规则已还原,求解器已构建并验证,分布式路径也在整套集群运行之前用封闭博弈检验过。求解这款游戏只会确认一个值,不会改变方法。

相关资源: