第一条规则:AI 不能看见它不该看见的牌
牌类 AI 最危险的 bug 往往不是出错牌,而是出了一张好得不真实的牌。浏览器里,完整牌局当然存在于内存:控制器要知道每张牌在哪里,动画要把牌从牌库发到各座位,结算也要读取真值。如果搜索函数直接拿到这份 state,它不需要故意作弊;一个方便的字段、一次调试序列化,已经足够把对手手牌送进去。
所以我们把真值和观察分成两层。Controller 独占完整区域账本;界面、Worker、提示和 AI 只能收到当前行动座位的 observation:自己的手牌、公开牌、每家剩余张数、已经发生的动作,以及规则允许推导出的约束。把某张未公开的牌换到另一个仍然相容的位置,送给 AI 的请求应该完全一样。红心大战里其他三家手牌不能进入 Worker;斗地主里两个农民也不能把彼此的暗牌当成队内消息。
这条边界还要一直延伸到 DOM、无障碍文本、动画 key、日志和搜索进度。把暗牌画成牌背,却在元素的 `data-card` 或 `aria-label` 里写出黑桃 A,视觉上没有泄漏,架构上仍然已经输掉了。我们后来把‘只允许 observation 进入 UI 与 AI’写成牌类内核的硬合同,不再靠每个游戏的作者自觉。
隐藏信息不是一种算法选项,而是一条从规则真值、页面渲染到搜索输入都不能跨越的信任边界。
从三款吃墩游戏,重写成一套真正的牌类内核
最初的牌桌框架服务于红心大战、黑桃叫牌和尤克牌。它很好用,但它暗中假定了太多事情:永远四个座位,一手动作就是出一张牌,桌面中央永远是一墩,收益永远是四个数,牌组也可以塞进固定的花色位集。只要一直做四人吃墩,这些假定都像合理的简化。
斗地主把六个边界同时撞开了:三个人、54 张牌和大小王、地主 20 张手牌、叫分与加倍、一次可以打出 1 到 20 张牌、中央区域放的是组合而不是一墩。与其在旧框架里加入一串 `if (gameId)`,我们做了 Card Engine V2:物理牌实例和规则牌面分离,区域账本统一负责牌张守恒,座位与收益改成动态向量,动作变成由游戏拥有的结构化 union,桌面中央则交给 trick、combo、meld 或攻防插件。
物理牌和牌面分开以后,两副牌里的两张红桃 8 可以是不同的 CardId,却在掼蛋的牌型判断里仍算同一个点数;皮纳克尔牌的重复牌面也不再需要假装自己来自一副标准扑克。规则只看 face 和数量,守恒、动画、回放和序列化认实例 ID。这个改动看似底层,却决定了后面所有多副牌游戏能不能共用一套引擎。
重写没有靠肉眼确认‘差不多’。迁移前我们冻结了 300 个初始状态、300 条完整牌局和 13,370 个动作前检查点;每一步保存阶段、行动者、合法动作集合、公开语义和结算结果。V2 逐项重放全等后,旧内核、旧 Worker 协议和旧 Wasm 才一起退出生产。保留的是可复现的行为证据,而不是一条永远不敢删除的兼容路径。
确定化不是随便把剩下的牌洗一遍
AI 看不见暗牌,但搜索仍然要向前模拟。最常见的做法是确定化:根据当前观察,抽一组可能的完整世界,在这些世界里比较动作。难点不在随机,而在‘可能’二字。叫墩牌里已经公开缺门的人不能再拿到那一门;尤克牌翻开的牌如果已经进入庄家手里,不能又出现在暗堆;卡纳斯塔里尚未露面的红 3 在特定阶段只能留在牌库;双副牌还要同时满足每种牌面的实体容量。
采样也不能把每种点数计数方案等概率对待。一个计数方案背后可能对应几百种具体发牌,另一个只有几种;均匀抽方案会系统性扭曲单张牌落到各座位的概率。我们的确定化按物理牌排列的条件分布加权,并在可手算的小局面里做边际和联合频率测试。牌数守恒只能证明没有凭空造牌,不能证明概率正确。
比较多个根动作时,我们尽量让它们使用同一批隐藏世界,也就是 common random numbers。否则动作 A 抽到容易的世界、动作 B 抽到困难的世界,采样噪声就会被误认为策略差异。这里还踩过一个很具体的坑:皮纳克尔牌的早期完整 Wasm 只在开始新世界时恢复状态,没有在每个根动作前恢复;前一个 rollout 消耗了后一个根的手牌。最终牌数看起来仍可能合理,但根排序已经失真。现在单根、全根、根重排和在目标根前插入另一根,统计都必须保持一致。
还有一条理论上更隐蔽的陷阱:不能在每个真值世界里先做一次全知最优,再把结果投票合并。那会把互相矛盾的世界策略拼成一个现实中无法执行的决定,也就是 strategy fusion。树的身份、行动者的选择和 rollout,都必须以当时能看到的 observation 为边界。
牌桌上也没有一种算法能包打天下
红心大战、黑桃叫牌和尤克牌适合 Action-Observation ISMCTS:树按公开动作与观察推进,每次迭代从相容世界出发,收益回传到信息集节点。斗地主的动作宽得多,同点数不同花色在策略上又常常等价;它在浏览器主路径使用 root-UCB 信息集 Monte Carlo,以点数多重集折叠等价根,再用小残局拆牌 DP 处理可以精算的尾段。
成组游戏则把搜索和精确优化混在一起。金拉米要在十来张牌中找互不重叠的 set 与 run,还要处理敲牌、贴牌、Gin 和反敲,组合分割适合记忆化 DP;搜索负责摸牌来源、弃牌与敲牌时机。卡纳斯塔在摸牌库还是拿整叠弃牌之间做隐藏世界比较,亮牌和万能牌分配继续交给精确 DP。为了贴上统一的 MCTS 标签而放弃这些可证明的小求解器,没有任何好处。
掼蛋只在批准的残局阈值进入同世界配对根搜索,开局仍由低成本策略处理超宽牌型;杜拉克牌的一次决定会穿过进攻、防守、添牌、拿牌、补牌和座位退出,rollout 必须理解完整 bout。抽牌游戏还带着真实机会节点,不能把未来牌库顺序当成搜索者已经知道的唯一剧本。算法的边界最终由信息结构和回合结构决定,而不是由游戏被放在网站的哪个分类里决定。
我们后来给根动作定了一条很实用的规则:它必须是 Controller 眼中的一个完整合法动作。金拉米的‘拿明牌再弃一张’如果在策略上需要联合比较,就不能让 TypeScript 先执行半步、Wasm 再接另一半;同样,叫分、传牌和多张组合也不能在搜索中途错误换手。缩小 ABI 不能以破坏回合原子性为代价。
搭档合作最容易被写成另一种作弊
搭档游戏不能只把个人评分相加。斗地主的两个农民目标是同一阵营先出完,掼蛋和黑桃叫牌的对家也要围绕共同结算行动。但共享目标不等于共享手牌。每个座位轮到自己时,都只能用自己的 observation 重新推理;另一名 AI 的真实手牌、搜索树、私有估值和随机 seed 都不能作为‘队友消息’传进来。合作来自 team payoff、公开出牌历史、剩余张数和牌权,而不是后台开了一条暗线。
让牌也必须受威胁门控。队友只剩一张并不自动意味着应该 Pass:如果地主只剩两张、正握着牌权,或者主要对手能在队友接手前直接结束,AI 必须先拦截。我们为斗地主同时保留‘安全让牌’和‘必须接管’的反例;把队友未公开手牌替换成另一份相容手牌后,行动者输入仍应不可区分。
牌类 AI 也不只负责出牌。黑桃叫牌的 Nil、尤克牌的独打、皮纳克尔牌的竞叫与选将,都在真正行牌之前改变整副牌的收益结构。叫分可以用经校准的启发式,出牌可以用信息集搜索,亮牌组合可以用精确枚举;强行让同一棵树从竞叫包办到最后一墩,通常只会得到一个很大、很慢又很难验证的系统。
斗地主升级后的 200 场同牌种子、地主/农民角色对调代理赛里,新 AI 赢了 124 场;更值得看的不是总数,而是农民从旧基线的 49/100 提到 68/100。它说明共享阵营收益、让牌和地主威胁门控至少没有退化成‘每个人只顾自己跑完’。这仍然只是固定代理基线,不是对真人牌力或比赛等级的声明。
Wasm 的收益,仍然来自把完整循环搬过去
牌类的热路径比棋类更碎:编码观察、确定化、生成合法动作、推进状态、rollout、结算,再把收益回传。如果树留在 TypeScript,只把一手牌力评分交给 Wasm,边界成本和对象构造很容易吃掉收益。我们的‘完整 Wasm’有严格含义:在获批阶段内,隐藏世界采样、后继生成、状态推进、搜索、rollout 和终局收益都在一次模块调用里完成;TypeScript 只负责不可信输入、权威根目录、取消和最终合法复核。
最早三款吃墩游戏给出了很干净的同工作量结果:红心大战固定模拟提速 8.94–9.23 倍,黑桃叫牌 6.51 倍,尤克牌 8.00 倍,而且逐根 visits、累计收益和最佳动作可以精确对齐。后来游戏更复杂,搜索拓扑不再相同:卡纳斯塔的生产路径从 45.87 ms 降到 0.70 ms,掼蛋从 1407.34 ms 降到 103.05 ms,皮纳克尔牌从 1630.15 ms 降到 34.72 ms,分别是 65.69、13.66 和 46.95 倍。此时我们不再伪称逐动作统计等价,而是分别检查规则语义、原动作合法、旧 oracle 下的估值差和换座完整对局。
也有反例。印度拉米的完整摸牌决策从 9.944 ms 降到 1.483 ms,但只有弃牌的极短阶段,TypeScript 是 0.143 ms,Wasm 反而要 0.610 ms,只有 0.235 倍。绝对时间仍不到 1 ms,所以产品继续走统一模块;报告里却必须把这个边界成本单独留下。‘用了 Wasm’不是结论,完整决策的 profile 才是。
斗地主的数字更夸张,但也更需要说明口径:113 根开局从 TypeScript 的 96 次模拟 512.3 ms,变为 Wasm 的 256 次模拟 16.3 ms,按模拟数归一约 83.8 倍;七根中盘归一约 261.7 倍。两侧工作量不同,所以这不是 raw latency 的同义对比,更不能直接换成牌力。我们同时跑角色对调完整牌局和相邻难度配对,避免把吞吐当成策略改进。
- ‘完整’必须说明覆盖阶段:行牌完整,不代表竞叫、传牌或亮牌也在同一个内核里。
- 相同固定工作量回答内核快多少;生产参数回答玩家等待多久、实际多算多少。
- 不同拓扑允许首选动作不同,但 observation、规则推进、阵营收益与最终合法性不能不同。
- 极短阶段如果边界比计算更贵,就应如实保留 TypeScript 或公开这笔成本。
浏览器里的 AI 还要像产品,而不是基准脚本
所有搜索仍然发生在玩家的浏览器。页面按需下载当前游戏的 Worker 和内容寻址 Wasm,成功后复用已编译模块与健康实例;手牌、隐藏世界、搜索树、评估和牌谱不需要发给服务器。服务端负责把程序送到设备,设备负责把这一手算完。
本地运行也意味着搜索必须随时能停。玩家重开、切换规则或离开页面时,旧一代结果不能晚到后落进新牌局。同步 Wasm 无法在计算中途接收取消消息,因此 deadline 或 abort 会终止并重建 Worker;只有一个合法动作时直接返回 Controller 的原对象,连 Worker 都不启动。一次动作的总 deadline 包含所有回退,Wasm 超时不能再给 TypeScript 一整份新预算。
生产链固定为同一隔离 Worker 内的 Wasm,再到 observation-only TypeScript 完整搜索,然后是同档无搜索策略,最后才是 Controller 的第一个合法动作。下载、编译、ABI 或自检失败会让当前会话不再反复尝试坏模块;普通超时、输入拒绝或搜索错误只降级这一手,下一次仍可重新尝试。浏览器连 Worker 都不可用时,主线程只运行短小启发式,不把完整搜索搬回来卡住牌桌。
难度也遵守同一条公平边界。入门档用同样的发牌和合法动作,只在合理近优候选里制造自然失误,不会因为识别人类座位而改牌,也不会故意丢掉唯一立即胜着。斗地主的固定代理赛里,休闲对入门 130/200、困难对休闲 115/200、专家对困难 110/200,方向正确但差距逐级收窄。这能证明产品阶梯没有倒挂,不能证明专家已经是职业牌手。
最后,可信度来自证据链,而不是一张胜率表
隐藏信息 AI 很容易用一个漂亮胜率掩盖错误:它可能看了暗牌,可能对每个根抽了不同世界,可能只因为排在前面多得到一次访问,也可能在 Wasm 内部 rollout 出了规则不允许的牌。我们因此把验证拆成几层:规则合法集合和公开状态转移;确定化守恒与边际概率;单根、全根和根重排隔离;固定 observation 与 seed 的可复现;同一实例连续一百次的内存水位;最后才是同一副牌换座、换阵营的完整对局。
TypeScript 和 Wasm 使用同一搜索拓扑时,可以严格比较每个根的 visits 与 valueSum;拓扑不同,就应该承认只能证明 semantic parity 和 non-regression。皮纳克尔牌的固定局面里两端同首选只有 1/8,但完整规则平价、8/8 返回原合法动作、旧评估遗憾、4/4 换队不倒退和累计 +33 都被分别记录。把这些结论混成一句‘两端一致’,反而会让真正的差异消失。
做完这一轮以后,我们对牌类 AI 的理解反而更朴素了:先决定它可以知道什么,再决定怎样采样不知道的部分;先把完整动作和阵营收益写对,再谈搜索树;先用 TypeScript 留下一套可解释的真相,再让 Wasm 承担真正昂贵的循环。只要这个顺序守住,AI 可以变快、变强,也可以在旧设备上体面地退回去,而不需要靠偷看一张牌来显得聪明。
下一篇可以继续谈难度设计:怎样让本地 AI 变得更容易,却不靠作弊发牌、固定送分或故意犯下不像人的错误。