CamelUpStateCalculator 是一个给《骆驼快跑》使用的状态计算器。输入当前棋盘上的骆驼位置、叠放顺序、陷阱以及还没有掷出的骰子,工具会枚举当前小回合结束后每只骆驼处于第 1、2、5 名的概率。
写这个工具的起因很简单:桌游桌上大家七嘴八舌地说“这只看起来要赢了”,可白骆驼、黑骆驼、叠放顺序和陷阱一搅和,直觉就开始打滑。我懒得跟人吵,干脆让计算机把后续所有可能都算一遍。它不替玩家下注,也不假装自己知道最佳策略,就做一件很具体的事:把规则允许的后续状态算出来。(:з」∠)
先把棋盘变成能计算的状态
棋盘被表示为长度为 20 的格子数组,每个格子保存当前位置上的骆驼数组。红、黄、绿、蓝、紫是玩家骆驼,白、黑是特殊骆驼;数组顺序表示叠放关系——这个顺序不是可以随便忽略的附加字段,它就是规则本身。陷阱则用 +1 和 -1 表示不同方向的影响。
1 | camelIndex[格子位置] = [下方骆驼, ..., 上方骆驼] |
所以移动一只骆驼时不能只把它的坐标加上步数。目标骆驼上面还压着别的骆驼?整叠一起搬。落点已经有骆驼了?新的一叠还得按规则插到正确位置。白、黑骆驼的移动方向和普通骆驼相反,陷阱还会改变步数或叠放位置。
我一开始就在这里偷过懒,结果概率表算出来头头是道,拿到桌上一对,完全不是那么回事。一个“数组顺序搞反”的小错误,足以让整张表看起来很合理,却与桌面上的真实规则完全相反。
move:把复杂规则集中在一个纯函数里
iterator.js 的核心是 move 和递归 iteration。move 先深拷贝当前状态,找到目标骆驼所在的格子,再取出目标骆驼和它上方的整组。落点为空就创建新数组;落点已有骆驼,就根据正向、反向、是否触发陷阱以及特殊骆驼规则,决定插到头部、尾部还是第一个玩家骆驼之前。
把规则塞进一个纯函数,纯粹是被递归逼的。输入状态不被递归分支互相污染,规则写错了也能单独测移动,不用每次都跑完整的概率枚举。递归只管“选哪枚骰子、走几步、下一个状态是什么”,完全不需要懂叠骆驼的细节。
这也是我处理桌游规则时很喜欢的做法:先把现实里的动作写成一个状态转换,再让搜索算法在这些转换上工作。只要 move 可信,后面加 UI、Worker 或概率统计都不用重新发明规则。
枚举而不是随机模拟
每枚彩色骰子有 1、2、3 三种步数,代码用 ratio: 2 表示相应的骰面权重;白、黑骰子则分别展开白骆驼和黑骆驼的三种步数。递归每次从剩余骰子里挑一枚,展开它的可能结果,直到所有骰子都处理完。
1 | 当前状态 + 剩余骰子 |
我没有用随机模拟抽几千次来估计概率,而是把所有可能的小回合状态完整枚举了一遍。为什么?因为骰子总共就那么几枚,状态空间算得动,而完整枚举意味着同一输入每次结果一模一样,没有随机误差,改了规则还能通过对比状态总数来检查。
代价当然也有:状态空间涨得飞快,浏览器主线程很快忙到连按钮都点不动。所以下一步不是改成近似算法,而是把同样的计算搬到 Web Worker。
名次统计:叠放顺序决定最后的答案
统计最终状态时,程序按棋盘格子从前往后遍历,再按照叠放数组从上方到下方遍历。白、黑特殊骆驼参与移动,但最终名次只记录五只玩家骆驼的第 1、2、5 名。
这一步很容易顺手写成“按坐标排序然后取前三”,然后被叠放规则坑一把:同一格上的骆驼不能算平局,前方格子优先,同格时上层压着下层。忽略这一点的话,特殊骆驼折腾半天带来的影响,会在最后一步悄悄消失。
输出结构保留每只骆驼的计数和 totalState,主线程再把它们换算成概率展示。留计数而不是只传小数,是调试时吃过亏:两个结果对不上的时候,能继续追问到底是状态总数变了,还是某只骆驼命中的终局数变了。
为什么一定要用 Web Worker
完整展开时,主线程如果直接递归计算,页面会直接装死。用户点了骰子,浏览器像是在思考宇宙的终极问题,迟迟没有反应。Worker 让计算在后台线程跑,主线程继续改状态、显示加载中、更新结果。
任务量够大时,项目还会把第一层拆出去。六枚骰子都可用时,第一层大约有 21 个分支:五枚彩色骰子各三个步数分支,白、黑骰子各展开对应骆驼的三个步数分支。主线程按 navigator.hardwareConcurrency 创建多个 Worker,把任务分发出去;某个 Worker 干完了就继续领下一个。
每个 Worker 只返回 stateCount 和 totalState,不把巨大的中间状态树传回主线程。主线程合并各分支计数,统一换算成概率并更新 Ant Design 表格。通信量小了,UI 和枚举逻辑的责任也分清了。
Worker 通信是有实际成本的:初始棋盘和剩余骰子要序列化,任务拆太细,消息传输会把并行收益吃光;拆太粗,又会有 Worker 提前闲着。所以项目只在第一层拆较大的分支,让每个 Worker 在本地完成更深的递归,再返回很小的计数结果。Worker 不是越多越快,计算量和通信量得保持比例。
并行不会改变概率模型
把第一层拆成多个 Worker 只是改变执行顺序,每个分支的规则和权重一点没变。所有分支仍然从同一个初始状态出发,用同样的 move 和递归逻辑,最后把计数相加就行。
这点我特别在意,因为并行化最怕的就是悄悄长出另一套逻辑。某个 Worker 用了不同的深拷贝方式、漏算了一枚骰子,结果可能只在特定输入下偏掉,而且偏得毫无声息。让 Worker 做相对纯粹的枚举,把状态合并和概率转换留在一个地方,就是为了少这种分叉。
状态树为什么会迅速变大
每一层递归都要选一枚还没用过的骰子,再展开它的步数或特殊骆驼分支;下一层又从剩下的骰子里选。这里既有“选哪一枚”的排列,也有“这一枚掷出几步”的分支,最后还要把叠放关系传播到终局。状态数量涨得很快,不可能把每个中间状态都存下来等最后处理。
所以 iteration 更像一台深度优先的计数器:生成一个状态,继续往下递归,到了终局就把相关名次计数加一,然后返回并释放这条分支。内存里只留当前路径需要的状态。深拷贝有成本,但能保证兄弟分支不会互相改写——对这种规则复杂、结果需要可信的工具,先保证状态隔离比过早抠微优化重要。
骰面权重也不能忘。普通骰子不同步数的概率、白黑特殊骰子的分支,都要以同一套计数约定进入 totalState。只把分支数相加却不同步分母,最后得到的数字看起来仍然像概率,实际上已经没法解释了。
怎样用小例子验证规则没有写反
完整枚举很适合做确定性测试。我的做法是先构造只有一枚剩余骰子的局面,手工列出 1、2、3 步后的落点;再构造一个落点有叠骆驼的局面,检查目标骆驼上方的整叠有没有一起移动;最后加入白、黑骆驼和正负陷阱,逐步对比 stateCount 和手算结果。
这些小局面比直接拿一盘复杂棋局看最终百分比好定位多了。结果不对时,先问 move 有没有产生正确的新棋盘,再问递归有没有漏骰子,最后才查名次统计和概率转换。规则、枚举、展示分成三段,调试路径短很多。
浏览器工具的界面也要尊重规则
页面用简单的 HTML 入口和浏览器端脚本,运行时加载 React、Ant Design、Lodash 等依赖。用户输入每个格子的骆驼、陷阱位置和可用骰子,触发计算后看结果。没有服务端、没有数据库,直接扔到静态托管就能跑。
状态输入界面其实和算法一样重要。骆驼叠放顺序不好表达,用户就会在输入阶段把状态填错;加载状态不明显,就会以为按钮坏了;结果只给一串百分比却不说是哪个剩余骰子集合,也没法复核。概率工具必须让“输入是什么、算了什么、结果什么意思”都尽量可见。
这个项目最后算出来的是什么
它算的不是“这局一定押哪只”,而是固定局面、固定剩余骰子集合下的条件概率。改一枚骰子、挪一个陷阱、调一叠骆驼的位置,重新算一遍,概率变化就有了可以追踪的来源。
对我来说,这个项目最有意思的地方,是把桌游里非常具象的东西——骆驼、格子、叠放、陷阱——翻译成纯函数、递归树、计数器和 Worker。规则没有因为变成代码而变简单,但至少它们变得可以被逐步检查了。至于最后押哪只?那还是留给桌边的人类吧 (`・ω・´)
如果再往前推一步,我想加一个“展开某个结果”的调试视图:点一只骆驼的概率,就能看到它命中的终局数量、相关骰子分支和典型移动路径。概率表告诉你结果,路径视图告诉你结果从哪来;对桌游这种规则密集的问题,两者放在一起比一个更大的百分比有说服力。
项目地址:github.com/Acceyuriko/CamelUpStateCalculator,在线工具:Camel Up State Calculator。