硬核模拟二十一点

发布: (2026年2月17日 GMT+8 09:16)
7 分钟阅读
原文: Dev.to

Source: Dev.to

痒感

它的开端和这些事情一贯的开端一样:我想要弄清楚数字。不是那种在网上看到的模糊的“赌场有 0.5 % 优势”之类的说法,而是实际的机制。当庄家在软 17 上要牌时,基本策略会如何改变?算牌到底能带来什么好处?需要多少次 riffle 洗牌才能让一副牌真正随机?

我本可以读一本书。相反,我写了一个模拟器。然后我重写了它。又一次重写了它。

正确的模拟

我给自己设定的第一条规则是 不走捷径。如果你在模拟二十一点以了解二十一点的玩法,就不能对那些不方便的部分进行近似。每张牌都来自一副发牌器。每次洗牌都遵循真实的物理过程。每一手牌都按照实际赌场规则结算:拆分、加倍、投降、保险,整个过程都要完整呈现。

洗牌模拟是最有趣的部分。完美的 Fisher‑Yates 洗牌实现起来非常简单,而且可以产生均匀随机的牌组。但赌场发牌员并不进行完美洗牌,他们使用的是交叉洗牌(riffle shuffle),而不完美交叉洗牌的数学已经被深入研究。大约需要七次交叉洗牌才能让牌组足够随机。少于这个次数时,牌序中仍会残留可被利用的结构。我实现了三种可配置精度的洗牌方式,以便精确研究不完美洗牌后还有多少信息会保留下来。

算牌是另一个深坑。基本的 Hi‑Lo 系统很直观:小牌记 +1,大牌记 -1,然后除以剩余的牌堆数。但职业玩家的偏差让情况变得复杂。正确的打法会根据真实计数(true count)而变化。有时你应该在面对庄家 10 时对 16 点要牌,有时则不该要。这个阈值取决于你在发牌器中的进度有多深。

我实现所有这些功能,就是为了让数据结果尽可能准确。

架构痴迷

模拟代码作为单体运行良好。我可以运行成千上万局手牌并获得统计上有效的结果。但我不断审视代码,看到许多想要修复的地方。

游戏逻辑与 I/O 纠缠在一起。策略决策与手牌结算耦合。状态是可变的,分散在半打对象中。它能工作,但这种代码让我感到不安——添加新特性意味着必须先理解所有其他特性。

于是我进行了四阶段的重写:

  1. 事件驱动架构
  2. 使用 frozen dataclass 的不可变状态
  3. 平台适配器,将引擎与任何特定接口解耦
  4. 全局异步支持

核心洞见是把每一次状态变化都视为一个事件。发牌 → 事件。玩家要牌 → 事件。庄家亮出暗牌 → 事件。这使得游戏引擎成为纯状态机:向它提供动作,得到新的状态。没有副作用,没有隐藏的变更,易于测试,易于验证。

这也意味着你可以将整局游戏记录为一系列事件,并在以后重放。当我的模拟产生看似错误的结果时,我可以逐步回顾每个决策,准确看到数学计算在哪儿偏离了我的预期。通常是数学正确,而我的预期错误。

每秒 350,000 手牌告诉你的事

在此阶段,模拟器大约可以每秒运行 350,000 局。这足以对几乎所有你想提出的问题得到统计上有意义的结果。

已确认的预期

  • 基本策略有效。
  • 计牌在理想条件下勉强有效。
  • 马丁格尔投注系统是一种可靠的慢慢破产的方式。

令人惊讶的发现

  • 二十一点的方差非常残酷。即使使用完美的基本策略,也可能连续数小时亏损。
  • 数学上说,经过数千手后你会略微占优,但“略微”在这句话里承担了大量的意义。
  • 我现在对为何计牌既需要巨额本金又需要铁一般的心理有了更深的直觉。

为什么要构建这个

人们出于不同的原因创建副项目。有些人想要发布产品,有些人想学习技术。我想要理解一个系统,而构建一个模拟是我所知道的最彻底的方式。

架构工作本身就是一种回报。并不是因为谁真的需要一个具有不可变状态管理的事件驱动二十一点引擎,而是因为这些模式是可以迁移的。使卡牌游戏可测试的同样关注点分离,也能让分布式系统更易调试。让我能够回放一局二十一点的事件驱动方法,也正是让你能够回放生产事故的方式。

代码在 GitHub 上,如果你想查看的话。温馨提示:这是一 个不断扩展的副项目——为一个卡牌游戏写了二万八千行 Python。某些项目就是这样的。

0 浏览
Back to Blog

相关文章

阅读更多 »

Python 中的实例变量和实例方法

Python 中的实例变量与实例方法 在面向对象编程(Object‑Oriented Programming,OOP)中,你必须掌握的两个概念是: - 实例变量 - 实例方法