炉石传说游戏机制分析
  • Post author:
  • Post category:游戏

炉石传说引擎深度解剖

关键词编年史、核心算法与数据驱动卡牌系统

一份面向工程师的、可落地的卡牌游戏引擎设计笔记

版本覆盖:2014 年经典版 → 2026 年《紫罗兰监狱越狱》

代码语言:Python 3.11(可直接运行的最小实现)


阅读指南

这份文档不是攻略,也不是卡表。它试图回答一个工程问题:

当你手里有一万两千张卡牌,每张卡的文本都可能改写规则本身,你要怎么写这个程序?

炉石传说是这个问题在商业上最成功的答案之一。它运行了十二年,每年发布三个资料片,每个资料片引入 145 张新卡和 1~3 个新关键词,而核心引擎的骨架从 2014 年至今没有推倒重来过。这本身就是一个值得研究的软件工程成就。

文档分七个部分:

部分内容你会得到什么
第一部分状态建模与基础设施一套 Entity-Component-Tag 的实体模型,理解为什么”一切皆实体”
第二部分核心算法事件总线、触发队列、光环重算、死亡结算——引擎的心脏
第三部分数据驱动的卡牌效果一套 Selector/Action/Condition DSL,让新卡不需要改引擎
第四部分关键词编年史2014→2026 每个关键词的设计意图、实现方案与代码
第五部分疑难时序案例研究十个真实存在过的 bug/裁定,以及它们暴露的架构缺陷
第六部分工程实践确定性随机、网络同步、回放、AI 模拟器、性能与测试
第七部分可借鉴清单与完整源码一个约 1500 行、能跑通完整对局的迷你引擎

如果你只有二十分钟,读 第 5 章(触发队列 vs 栈)第 10 章(从硬编码到 DSL)第 27 章(关键词的六大范式)。这三章是整份文档的骨架,其余都是它们的展开与佐证。

关于本文的知识来源与准确性

  • 公开事实(版本时间线、关键词文本、卡牌交互裁定)来自暴雪官方公告、Hearthstone Wiki 与补丁说明,末尾附来源。
  • 内部实现细节(暴雪的实际类名、脚本格式)是逆向推断与合理重构,不是官方源码。炉石客户端是 Unity + C#,社区通过 cardxml0.unity3dCardDefs.xml 的解包获得了大量结构信息,本文在此基础上重建了一套等价的、自洽的设计,并用 Python 表达以便阅读和运行。
  • 凡是我在推断的地方,会明确写”推断“。

第 0 章 为什么这个系统值得研究

0.1 规模:一个被低估的数字

到 2026 年,炉石传说的可收集卡牌总数超过 6000 张(标准+狂野+酒馆战棋+佣兵,去重后的”可玩实体”更多,因为衍生物、附魔、任务奖励都是独立实体)。这些卡牌:

  • 分属 11 个职业 + 中立
  • 拥有 约 60 个正式关键词
  • 10+ 种法术学派12 种随从类型
  • 每一张都可能与其他任意一张产生交互

如果用最朴素的方式实现——每张卡一个 if——你会得到一个 6000 分支的函数,且任意两张卡的交互都是未定义行为。真实的组合空间是 \binom{6000}{2} \approx 1.8 \times 10^7 对,加上三张卡的交互就是 10^{10} 量级。

没有任何测试策略能覆盖这个空间。 唯一的出路是:让交互从规则中涌现,而不是被逐一定义。这是整个设计的第一性原理。

0.2 约束:这不是一个可以随便重构的系统

炉石有几个特殊约束,让它的架构选择与一般游戏不同:

  1. 服务器权威、客户端零逻辑。所有规则计算在服务器完成,客户端只播放一串”发生了什么”的指令流(社区称为 PowerHistory)。这意味着引擎必须能把状态变化序列化成一个线性的、可重放的事件流
  2. 狂野模式永不轮换。2014 年的卡必须和 2026 年的卡在同一局里正确交互。这排除了”新版本重写旧卡”的选项。
  3. 移动端。引擎要在十年前的手机上跑动画,状态同步的带宽和客户端算力都很紧张。
  4. 热更新卡牌。平衡性调整(改费用、改身材、改文本)必须能通过数据下发完成,不能每次都发客户端版本。

约束 1 和 4 联手,把设计推向了一个必然的方向:卡牌逻辑必须是数据,引擎必须是解释器

0.3 与万智牌的对比:一个关键的分岔

理解炉石最快的方式,是看它在哪里故意背离了万智牌(MTG)。

维度万智牌炉石传说工程后果
效果解析结构栈(Stack),后进先出,可响应队列(Queue),先进先出,不可响应炉石不需要优先权系统,状态机简单一个数量级
对手回合互动瞬间、闪现,随时可打只有奥秘/亡语等预设触发炉石不需要在回合中间等待对手输入
状态动作有完整 SBA(State-Based Actions)有简化版”死亡结算阶段”炉石的 SBA 只处理死亡和几个特例
手牌上限7 张,回合末弃牌10 张,超出直接烧掉更简单,无需弃牌决策
战场大小无限7 个位置,且位置有意义引入了”位置”这个额外状态维度
规则文本需要完整规则手册(250+ 页)关键词自解释卡面文本即规则,减少歧义

队列 vs 栈是最重要的一条。 它的直接后果是:炉石里”同时”触发的多个效果按上场顺序依次结算,而且中间不允许插入新的响应。这让整个引擎变成了一个可以单线程跑完的状态机,而不是一个需要维护”优先权轮转”的交互式系统。

我们会在第 5 章详细展开这一点,因为几乎所有炉石特有的”奇怪”裁定都源于它。

0.4 本文的迷你引擎

为了让讨论落地,全文围绕一个我称之为 stoneheart 的 Python 迷你引擎展开。它的规模:

stoneheart/
├── entity.py # 实体、标签、组件 ~180 行
├── zones.py # 区域系统 ~120 行
├── events.py # 事件总线与触发队列 ~200 行
├── auras.py # 光环与持续效果重算 ~150 行
├── actions.py # Action DSL ~320 行
├── selectors.py # Selector DSL ~180 行
├── game.py # 主循环、阶段、死亡结算 ~380 行
├── keywords.py # 六十个关键词的实现 ~420 行
└── cards.py # 卡牌定义(数据) ~按需

它能做到:

  • 完整的出牌 / 攻击 / 英雄技能 / 回合流转
  • 战吼、亡语、光环、奥秘、圣盾、嘲讽、突袭、冲锋、剧毒、吸血、风怒、潜行、沉默、冻结……
  • 发现、适应、任务、任务链、法术迸发、狂乱、超杀、荣誉击杀、磁力、回响、可交易、打捞、挖掘、锻造、灌注、倒带……
  • 确定性随机(同一 seed 完整复现)
  • 事件流序列化(可用于回放与网络同步)

第七部分给出完整可运行的源码。中间章节的代码片段都是从它里面摘出来的。


第一部分 基础设施:状态如何被建模


第 1 章 一切皆实体:Entity-Component-Tag 模型

1.1 第一个设计决策:卡牌不是对象

新手实现卡牌游戏时的第一反应是面向对象:

class Minion:
def __init__(self, name, cost, attack, health):
...
class Spell:
...
class Weapon:
...
class TauntMinion(Minion): # ← 灾难从这里开始
...

这条路会在第三张卡就崩溃。原因很简单:炉石里的类型是可变的

  • 《变形术》把随从变成 1/1 绵羊——绵羊是同一个实体吗?(不是,是新实体,但对手的”你的随从死亡时”不触发)
  • 《幽灵之爪》让随从变成武器?
  • 《海巨人》的费用会随场上随从数变化——费用是属性还是计算结果?
  • 《铜须》给随从加嘲讽——你要 TauntMinion.__class__ 换掉吗?
  • 一个被沉默的、拥有圣盾的、被冻结的、有 +3/+3 附魔的、被《心灵控制》抢过的随从,是什么类?

正确答案是:没有类。只有实体和标签。

1.2 实体(Entity)

一个实体是一个整数 ID + 一张标签表。仅此而已。

# entity.py
from __future__ import annotations
from enum import IntEnum, auto
from typing import Any

class Tag(IntEnum):
"""所有可查询的状态维度。对应炉石内部的 GAME_TAG 枚举。"""
# ---- 身份 ----
CARD_ID = auto() # 卡牌定义 ID,如 "EX1_001"
ENTITY_ID = auto() # 本局唯一实体 ID
CONTROLLER = auto() # 当前控制者(可被夺取)
OWNER = auto() # 原始拥有者(用于"返回其拥有者手牌")
ZONE = auto() # 所在区域
ZONE_POSITION = auto() # 区域内位置(1-based,战场/手牌有意义)
CARDTYPE = auto() # MINION / SPELL / WEAPON / HERO / HERO_POWER / ENCHANTMENT / LOCATION
CARDRACE = auto() # 随从类型(野兽/机械/恶魔/元素/亡灵/海盗...)
CLASS = auto() # 职业
RARITY = auto()
SPELL_SCHOOL = auto() # 法术学派(火焰/冰霜/暗影/自然/奥术/神圣)

# ---- 数值 ----
COST = auto()
ATK = auto()
HEALTH = auto() # 最大生命值
DAMAGE = auto() # 已受到的伤害(注意:不是当前生命值!)
ARMOR = auto()
DURABILITY = auto()

# ---- 布尔关键词 ----
TAUNT = auto()
DIVINE_SHIELD = auto()
CHARGE = auto()
RUSH = auto()
STEALTH = auto()
POISONOUS = auto()
LIFESTEAL = auto()
WINDFURY = auto()
MEGA_WINDFURY = auto()
FROZEN = auto()
IMMUNE = auto()
ELUSIVE = auto() # 不可被法术/技能选中
REBORN = auto()
SILENCED = auto()
DORMANT = auto() # 休眠
UNTOUCHABLE = auto() # 完全不可交互(如任务、地标的某些状态)

# ---- 运行时状态 ----
EXHAUSTED = auto() # 本回合不能攻击(召唤失调)
NUM_ATTACKS = auto() # 本回合已攻击次数
NUM_TURNS_IN_PLAY = auto()
SPELLPOWER = auto()
ATTACHED = auto() # 附魔宿主
TO_BE_DESTROYED = auto() # 死亡标记(等待结算)
PENDING_DESTROY = auto()

# ---- 玩家级 ----
MANA_CRYSTALS = auto()
MANA_AVAILABLE = auto()
OVERLOAD_OWED = auto() # 下回合要锁的水晶
OVERLOAD_LOCKED = auto() # 本回合已锁的水晶
FATIGUE = auto()
COMBO_ACTIVE = auto()
CORPSES = auto() # 尸块(亡灵天灾)


class Entity:
__slots__ = ("id", "tags", "game", "_deathrattles", "_triggers", "_enchantments")

def __init__(self, eid: int, game: "Game", **tags):
self.id = eid
self.game = game
self.tags: dict[Tag, Any] = {}
self._enchantments: list["Entity"] = []
self.tags.update(tags)

# ---- 标签读写 ----
def __getitem__(self, t: Tag):
return self.tags.get(t, 0)

def __setitem__(self, t: Tag, v):
old = self.tags.get(t, 0)
if old == v:
return
self.tags[t] = v
# 标签变化本身是一个可观测事件,这是网络同步的基础
self.game.record_tag_change(self, t, old, v)

def has(self, t: Tag) -> bool:
return bool(self[t])

注意 DAMAGE 这个设计。炉石不存储”当前生命值”,而是存储”最大生命值”和”已受伤害”:

    @property
def current_health(self) -> int:
return self[Tag.HEALTH] - self[Tag.DAMAGE]

为什么? 因为这样 +X/+X 光环的加减法是幂等的。想象一下:

  • 5/5 随从受到 3 点伤害 → HEALTH=5, DAMAGE=3,当前 2 血
  • 《王者祝福》给 +4/+4 → HEALTH=9, DAMAGE=3,当前 6 血 ✓
  • 被沉默 → 移除附魔,HEALTH=5, DAMAGE=3,当前 2 血 ✓

如果存的是”当前生命值”,沉默时你就不知道该扣回多少了。这是一个极其重要的建模决策,几乎所有卡牌游戏引擎都是这么做的(MTG 的规则文本里也是”伤害标记”)。

它还解释了一个新手常见的困惑:为什么”沉默一个被《暴风城勇士》光环加过血、且已受伤的随从”可能直接杀死它?因为 HEALTH 降回原值后 HEALTH - DAMAGE <= 0

1.3 标签的三层来源:Base / Enchantment / Aura

一个随从的攻击力从哪来?至少三个地方:

  1. 基础值:卡牌定义里写的(EX1_001 基础 3/2)
  2. 附魔(Enchantment):持久的、附着在实体上的修饰(《王者祝福》+4/+4,永久生效直到沉默或死亡)
  3. 光环(Aura):由另一个实体持续提供的、条件性修饰(《暴风城勇士》给其他随从 +1/+1,源死亡立即失效)

炉石的做法是把三者在同一个标签上叠加计算,但用不同的机制维护:

class TagView:
"""标签的三层求值:base -> enchantments -> auras"""

@staticmethod
def compute(entity: Entity, tag: Tag) -> int:
base = entity.tags.get(tag, 0)
if entity.has(Tag.SILENCED) and tag in SILENCEABLE_TAGS:
base = entity.base_definition.get(tag, 0)
return base + entity.game.aura_bus.delta(entity, tag)
ench = sum(e.effect_delta(tag) for e in entity._enchantments)
aura = entity.game.aura_bus.delta(entity, tag)
return base + ench + aura

但真实引擎不会每次读标签都重算——太慢了。实际做法(推断,基于社区对 PowerHistory 的观察)是:

  • 附魔:直接修改宿主的 tag 值(写入式),附魔实体记录自己改了什么,以便移除时回滚。
  • 光环:由 AuraBus 在每次”状态变化”后统一重算,把差值写入 tag。

也就是说,entity[Tag.ATK] 读到的永远是最终值,重算发生在写入侧而不是读取侧。这是一个典型的”读多写少 → 用空间/写入换读取速度”的权衡。第 6 章会详细讲重算的时机。

1.4 组件:为什么标签不够

标签只能存整数。但有些东西不是整数:

  • 亡语要执行什么?
  • 战吼的目标筛选条件?
  • 触发器订阅了哪些事件?

这些是行为,存在卡牌定义(CardDef)里,实体只持有一个指向定义的引用:

from dataclasses import dataclass, field
from typing import Callable

@dataclass(frozen=True)
class CardDef:
card_id: str
name: str
cost: int
card_type: str
attack: int = 0
health: int = 0
text: str = ""
race: str | None = None
klass: str = "NEUTRAL"
spell_school: str | None = None

# 静态关键词(进场即拥有)
keywords: frozenset[str] = frozenset()

# 行为脚本:全部是 Action 树,不是 Python 函数(见第三部分)
battlecry: "Action | None" = None
deathrattle:"Action | None" = None
combo: "Action | None" = None
spell: "Action | None" = None # 法术效果
triggers: tuple["TriggerDef", ...] = ()
auras: tuple["AuraDef", ...] = ()
targeting: "Selector | None" = None # 战吼/法术的合法目标
choose_one: tuple[str, ...] = () # 抉择的子卡 ID

关键点:CardDef 是 frozen 的,全局唯一,所有同名卡共享。 实体只存 CARD_ID,行为通过查表获得。这让 6000 张卡的定义在内存里只占一份,而一局游戏里的实体数(通常 < 200)才需要独立的 tag 表。

1.5 实体 ID 与”同一性”问题

炉石里最微妙的问题之一:什么时候实体还是”同一个”?

操作是同一实体吗后果
受伤附魔保留
被 buff
被沉默但清除所有附魔和文本
被《变形术》变成绵羊(实体 ID 不变,CARD_ID 改变)附魔全部丢失,亡语不触发
死亡后被《复活术》复活,新实体原实体永久留在墓地
从战场返回手牌,但清除附魔(回到基础状态)这是《撒满谎言的书》能”洗白”的原因
被《心灵控制》是,改 CONTROLLEROWNER 不变,所以”返回拥有者手牌”会送回去
被复制(《克隆》)否,新实体复制的是当前状态快照还是基础卡?取决于卡(这是历史 bug 温床)

变形(Transform)是最反直觉的:实体 ID 保留,但一切属性重置为新卡的定义。这解释了为什么”变形术打圣盾随从”会连圣盾一起没掉,也解释了为什么变形不触发亡语(因为原实体没有”死亡”,只是”变了”)。

def transform(entity: Entity, new_card_id: str):
"""变形:保留 ENTITY_ID 与 ZONE_POSITION,其余全部重置。"""
new_def = CARD_DB[new_card_id]
keep = {
Tag.ENTITY_ID: entity[Tag.ENTITY_ID],
Tag.ZONE: entity[Tag.ZONE],
Tag.ZONE_POSITION: entity[Tag.ZONE_POSITION],
Tag.CONTROLLER: entity[Tag.CONTROLLER],
# 注意:OWNER 也保留,变形不改变归属
Tag.OWNER: entity[Tag.OWNER],
}
# 移除所有附魔(不触发"附魔移除"事件——变形是"抹除"不是"移除")
for e in list(entity._enchantments):
e.detach_silently()
entity._enchantments.clear()
# 注销所有触发器
entity.game.event_bus.unregister_all(entity)
entity.tags.clear()
entity.tags.update(new_def.as_tags())
entity.tags.update(keep)
entity.game.event_bus.register_card(entity) # 注册新卡的触发器

一个真实历史案例:《野性成长》与《变形术》的顺序。早期版本里,如果变形发生在一个正在结算亡语的随从身上,会导致亡语指向一个已经变成绵羊的实体,从而执行”绵羊的亡语”(没有)。这就是为什么现代实现里,亡语必须在死亡瞬间快照它需要的所有信息(第 5 章展开)。


第 2 章 区域(Zone):位置即状态

2.1 八个区域

class Zone(IntEnum):
INVALID = 0
PLAY = 1 # 战场(含英雄、武器、英雄技能)
DECK = 2
HAND = 3
GRAVEYARD = 4 # 墓地
REMOVEDFROMGAME = 5 # 移出游戏(如被吞噬)
SETASIDE = 6 # 暂存区(衍生物生成前、抉择的子卡、任务未激活时)
SECRET = 7 # 奥秘区
SIDEBOARD = 8 # 备选卡组(旅客/星舰/任务链的关联卡)

SETASIDE 是最容易被忽略但最重要的一个。它是”存在但不在任何可见位置”的实体的家:

  • 《抉择》卡的两个子选项(它们是真实实体,可被”复制手牌中的卡”抓到过——历史 bug)
  • 尚未召唤的衍生物(在 PLAY 之前先在 SETASIDE 里创建,走完 SUMMONING 事件才落地)
  • 《巨型》(Colossal)的附属部件:主体入场时,部件先在 SETASIDE 生成再摆到两侧
  • 《星舰》(Starship)尚未发射时的零件

有了 SETASIDE,”创建实体”和”实体进入战场”就被拆成了两步,中间可以插入触发(比如《米尔豪斯》改变法术费用、奥秘《镜像实体》复制刚入场的随从)。

2.2 位置(ZONE_POSITION)为什么重要

炉石的战场是有序的 7 格。位置参与规则的地方比想象中多:

机制位置的作用
《暴风城勇士》类光环不依赖位置(全场)
《磁力》打在机械左侧才合体
《流放》(Outcast)在手牌最左/最右时触发
《碎裂》(Shatter,2026)分裂成两半放在手牌两端,相邻时合并
《深水领主》/《宝库门卫》“相邻随从”
随机目标的分布位置本身不影响,但召唤位置影响后续相邻关系
亡语召唤衍生物原随从的位置召唤
触发顺序按上场先后,不是按位置!(见第 5 章)

最后一条是个大坑:很多人以为触发顺序是”从左到右”,其实是”按 ENTITY_ID 升序”,也就是上场时间顺序。一个从左边召唤但更晚上场的随从,触发顺序在后面。

# zones.py
class ZoneManager:
MAX_SIZE = {Zone.PLAY: 7, Zone.HAND: 10, Zone.SECRET: 5, Zone.DECK: 60}

def __init__(self, game):
self.game = game
# (player_id, zone) -> list[Entity],list 的顺序就是 ZONE_POSITION
self._z: dict[tuple[int, Zone], list[Entity]] = {}

def get(self, player: int, zone: Zone) -> list[Entity]:
return self._z.setdefault((player, zone), [])

def move(self, e: Entity, to_zone: Zone, position: int | None = None) -> bool:
"""移动实体。返回 False 表示失败(如战场已满)。"""
src = e[Tag.ZONE]
pid = e[Tag.CONTROLLER]

# 1. 容量检查(在移除之前!否则会出现"离开旧区但进不去新区"的幽灵实体)
cap = self.MAX_SIZE.get(to_zone)
dst_list = self.get(pid, to_zone)
if cap is not None and len(dst_list) >= cap:
if to_zone == Zone.HAND:
# 手牌满 -> 烧牌,是一个可观测事件
self.game.emit(Event.CARD_BURNED, entity=e)
return self.move(e, Zone.GRAVEYARD)
if to_zone == Zone.PLAY:
# 战场满 -> 召唤失败,实体消失(不进墓地,不触发亡语)
self.game.emit(Event.SUMMON_FAILED, entity=e)
self._remove(e)
return False
return False

# 2. 从原区域移除并重排位置
if src != Zone.INVALID:
old = self.get(pid, src)
if e in old:
old.remove(e)
self._reindex(old)

# 3. 插入新区域
if position is None or position > len(dst_list):
dst_list.append(e)
else:
dst_list.insert(position, e)
self._reindex(dst_list)
e[Tag.ZONE] = to_zone
return True

@staticmethod
def _reindex(lst: list[Entity]):
for i, x in enumerate(lst):
x.tags[Tag.ZONE_POSITION] = i + 1 # 直接写 tags,避免触发 record_tag_change 洪水

_reindex 直接写 tags 而不是走 __setitem__,是一个真实的性能考量:召唤一个随从会导致它右边所有随从的位置变化,如果每个都发一条网络同步消息,带宽会爆炸。真实的炉石协议里,ZONE_POSITION 的变化确实是批量下发的。

2.3 战场已满:一个精心设计的失败路径

“战场满了会怎样”是一道经典面试题,因为它有五种不同答案:

  1. 主动出牌:出不了,UI 直接禁用。
  2. 战吼召唤(如《野猪骑士》):召唤失败,什么都不发生,不触发亡语,不进墓地,实体直接消失
  3. 亡语召唤(如《蜘蛛坦克》):同上,但注意是在死亡结算阶段判定的,此时可能已经空出位置了。
  4. 《军情七处》类偷取对方随从:如果我方满了,随从留在原地
  5. 《镜像实体》类奥秘:如果满了,奥秘照样消耗(这是被投诉过很多次的裁定)。

关键:“召唤失败”和”死亡”是两件完全不同的事。前者根本没进入 PLAY 区,所以不存在死亡,也就没有亡语。

def summon(game, card_id: str, controller: int, position: int | None = None) -> Entity | None:
e = game.create_entity(card_id, controller, zone=Zone.SETASIDE)
# 先在 SETASIDE 创建,给 "召唤时" 触发一个观察窗口
game.emit(Event.PRE_SUMMON, entity=e)
ok = game.zones.move(e, Zone.PLAY, position)
if not ok:
return None # 召唤失败:无亡语、无墓地
e[Tag.EXHAUSTED] = 0 if e.has(Tag.CHARGE) else 1
game.event_bus.register_card(e)
game.aura_bus.mark_dirty()
game.emit(Event.SUMMON, entity=e) # 此刻《镜像实体》等才响应
return e

第 3 章 游戏主循环与阶段模型

3.1 一个回合里究竟发生了什么

炉石一个回合的完整阶段(推断,基于社区逆向与实测行为):

回合开始
├─ 1. BEGIN_TURN:解锁过载水晶、+1 最大法力、法力回满
├─ 2. 装备/随从的 EXHAUSTED 重置,NUM_ATTACKS 归零
├─ 3. 触发所有 "回合开始时" 效果(按 ENTITY_ID 顺序)
├─ 4. [死亡结算阶段]
├─ 5. 抽一张牌(疲劳判定)
├─ 6. [死亡结算阶段]
├─ 7. MAIN 阶段:玩家可以出牌 / 攻击 / 用英雄技能,每个动作后:
│ ├─ 执行动作
│ ├─ 结算触发队列(直到清空)
│ └─ [死亡结算阶段](可能反复,直到无新死亡)
├─ 8. END_TURN:触发所有 "回合结束时" 效果
├─ 9. [死亡结算阶段]
├─ 10. 疲劳/临时卡清理(《回响》副本消失、《临时》卡弃掉)
└─ 11. 交换 currentPlayer

其中方括号里的”死亡结算阶段”是引擎里最重要的一段,第 7 章专门讲。

3.2 主循环代码

# game.py(节选)
class Game:
def start(self):
self.phase = Phase.BEGIN_GAME
self._mulligan()
self._fire_start_of_game_triggers() # 《开局时》类效果
self.turn = 0
self.current = self.first_player
self.begin_turn()

def begin_turn(self):
p = self.player(self.current)
self.turn += 1

# 1. 过载解锁 —— 注意:是"解锁上回合欠的",然后把 owed 转成 locked
p[Tag.OVERLOAD_LOCKED] = p[Tag.OVERLOAD_OWED]
p[Tag.OVERLOAD_OWED] = 0

# 2. 法力水晶
p[Tag.MANA_CRYSTALS] = min(10, p[Tag.MANA_CRYSTALS] + 1)
p[Tag.MANA_AVAILABLE] = p[Tag.MANA_CRYSTALS] - p[Tag.OVERLOAD_LOCKED]

# 3. 解除召唤失调
for e in self.zones.get(self.current, Zone.PLAY):
e[Tag.EXHAUSTED] = 0
e[Tag.NUM_ATTACKS] = 0
e[Tag.NUM_TURNS_IN_PLAY] += 1
p.hero[Tag.NUM_ATTACKS] = 0
p.hero_power[Tag.EXHAUSTED] = 0

# 4. 回合开始触发
self.emit(Event.TURN_START, player=self.current)
self.resolve_queue()
self.process_deaths()

# 5. 抽牌
self.draw(self.current, 1)
self.resolve_queue()
self.process_deaths()

self.phase = Phase.MAIN

def end_turn(self):
self.phase = Phase.END_TURN
self.emit(Event.TURN_END, player=self.current)
self.resolve_queue()
self.process_deaths()

# 临时卡清理
for e in list(self.zones.get(self.current, Zone.HAND)):
if e.has(Tag.TEMPORARY) or e.echo_copy:
self.zones.move(e, Zone.GRAVEYARD)

self.current = 1 - self.current
self.begin_turn()

3.3 疲劳:一个只有三行但值得聊的机制

    def draw(self, pid: int, n: int = 1):
for _ in range(n):
deck = self.zones.get(pid, Zone.DECK)
if not deck:
p = self.player(pid)
p[Tag.FATIGUE] += 1
self.damage(p.hero, p[Tag.FATIGUE], source=None) # source=None 很关键
continue
card = deck.pop() # 牌库顶
self.zones.move(card, Zone.HAND)
self.emit(Event.DRAW, entity=card, player=pid)

source=None 意味着疲劳伤害没有来源。这不是偷懒,是规则要求:

  • 疲劳伤害不能被吸血(因为吸血是”来源的属性”)
  • 疲劳伤害不受法术伤害加成影响
  • 《受虐狂》类”受到伤害时”效果触发(伤害事件本身存在)
  • 《免疫》挡住疲劳伤害

一个 source 字段就把这四条规则同时表达了。这是良好建模的典型收益:规则不需要被单独编码,它从数据结构里自然导出

3.4 出牌(PlayCard)的完整流程

出牌是引擎里分支最多的操作。完整流程(推断):

def play_card(self, card: Entity, target: Entity | None = None,
position: int | None = None, choose: int = 0):
pid = card[Tag.CONTROLLER]
p = self.player(pid)

# ---- 阶段 A:合法性 ----
cost = self.compute_cost(card) # 光环/附魔/折扣后的最终费用
assert p[Tag.MANA_AVAILABLE] >= cost
assert self.is_legal_target(card, target)

# ---- 阶段 B:支付与离手 ----
p[Tag.MANA_AVAILABLE] -= cost
self.emit(Event.PRE_PLAY, entity=card, target=target) # 《米尔豪斯》等在此响应

# 连击标记:出牌前判断"本回合是否已出过牌"
combo = p[Tag.COMBO_ACTIVE]

self.zones.move(card, Zone.SETASIDE) # 先离开手牌
self.emit(Event.PLAY_CARD, entity=card, target=target) # 《奥术巨龙》"你使用一张法术后"

# ---- 阶段 C:按类型分发 ----
ctype = card[Tag.CARDTYPE]
if ctype == CardType.MINION:
ok = self.zones.move(card, Zone.PLAY, position)
if ok:
card[Tag.EXHAUSTED] = 0 if card.has(Tag.CHARGE) else 1
self.event_bus.register_card(card)
self.aura_bus.mark_dirty()
self.emit(Event.SUMMON, entity=card)
self.emit(Event.MINION_PLAYED, entity=card)
# 战吼 / 连击
script = card.defn.combo if (combo and card.defn.combo) else card.defn.battlecry
if script and not self._battlecry_suppressed(card):
self.run(script, source=card, target=target, choose=choose)
elif ctype == CardType.SPELL:
# 奥秘先检查是否被反制
if self.check_counter(card):
self.zones.move(card, Zone.GRAVEYARD)
else:
self.emit(Event.SPELL_CAST, entity=card, target=target) # 奥秘《法术反制》在此
self.run(card.defn.spell, source=card, target=target, choose=choose)
self.zones.move(card, Zone.GRAVEYARD)
elif ctype == CardType.WEAPON:
self.equip(pid, card)
elif ctype == CardType.LOCATION:
self.zones.move(card, Zone.PLAY, position)
card[Tag.LOCATION_COOLDOWN] = 1

# ---- 阶段 D:出牌后 ----
p[Tag.COMBO_ACTIVE] = 1
self.emit(Event.AFTER_PLAY, entity=card)
self.resolve_queue()
self.process_deaths()

有几处值得注意:

(1)为什么随从先落地再执行战吼?

因为战吼要能”看到自己”。《暗影狂乱》类效果需要计算包含自己在内的场面;《砰砰博士》召唤的机器人要在自己旁边。这也是为什么《野猪骑士》在满场时”召唤失败”——因为主体已经占了第 7 格。

(2)为什么 emit(PLAY_CARD) 在移动到 SETASIDE 之后?

因为”打出一张牌”的触发器(如《鱼人领军》”你使用一张鱼人后”)不应该在牌还在手上时看到它。同时它必须在牌进入战场之前,这样《镜像实体》才能区分”打出的随从”和”召唤的随从”。

(3)连击的判定时机

combo 变量在离手之前取值,因为出这张牌本身会把 COMBO_ACTIVE 置为 1。经典的”先取值后置位”模式。

3.5 费用计算:一个纯函数

def compute_cost(self, card: Entity) -> int:
base = card.defn.cost
# 1. 附魔式折扣(《埃德温》、《瑟拉赞恩》等永久修改)
base += sum(e.cost_delta for e in card._enchantments)
# 2. 光环式折扣(《索瑞森大帝》、《学徒》,源消失即失效)
base += self.aura_bus.delta(card, Tag.COST)
# 3. 动态成本(《海巨人》"你的随从每有一个,费用减 1")
if card.defn.dynamic_cost:
base = card.defn.dynamic_cost(self, card)
# 4. Prepare 折扣(2026 新机制,见第 26 章)
base -= card[Tag.PREPARE_DISCOUNT]
return max(0, base)

顺序很重要。真实炉石里的顺序是:先加后减、先乘后加(对于 *2 类效果),最后 clamp 到 0。历史上有过若干因为顺序不同导致的”负费用变正费用”bug。


第二部分 核心算法:引擎的心脏


第 4 章 事件总线与触发系统

4.1 事件的分类学

炉石里所有”当……时”的效果,本质上都是事件订阅。但事件的种类需要精心切分。一个常见的错误是把事件切得太粗:

# ❌ 错误示范
emit("MINION_DIED", minion)

这样《亡语》《复生》《你的随从死亡后》《友方野兽死亡后》就得各自在回调里写一堆 if。正确的做法是事件携带足够结构化的上下文,让订阅者用声明式条件过滤

# events.py
class Event(IntEnum):
# ---- 回合 ----
TURN_START = auto(); TURN_END = auto()
GAME_START = auto()

# ---- 卡牌流转 ----
DRAW = auto(); CARD_BURNED = auto(); DISCARD = auto()
PRE_PLAY = auto(); PLAY_CARD = auto(); AFTER_PLAY = auto()
MINION_PLAYED = auto(); SPELL_CAST = auto(); AFTER_SPELL = auto()
WEAPON_EQUIPPED = auto()

# ---- 战场 ----
PRE_SUMMON = auto(); SUMMON = auto(); SUMMON_FAILED = auto()
PROPOSED_ATTACK = auto() # 攻击宣言(此时可改目标:嘲讽、《铜须》)
ATTACK = auto() # 攻击确认(《砰砰法师》在此)
AFTER_ATTACK = auto()

# ---- 伤害与治疗 ----
PRE_DAMAGE = auto() # 可修改伤害数值(圣盾在此消耗)
DAMAGE = auto() # 伤害已结算
HEAL = auto(); OVERHEAL = auto()
ARMOR_GAINED = auto()

# ---- 生死 ----
PROPOSED_DEATH = auto() # 被标记死亡(《恩佐斯的宝箱》可救)
DEATH = auto() # 确认死亡,亡语在此
AFTER_DEATH = auto()

# ---- 其他 ----
HERO_POWER_USED = auto() # 励志
SECRET_REVEALED = auto()
MANA_SPENT = auto()
TAG_CHANGED = auto() # 元事件,用于光环脏标记


@dataclass
class EventCtx:
"""事件上下文。所有字段都可选,订阅者用 Condition 声明式匹配。"""
kind: Event
entity: Entity | None = None # 事件主体
source: Entity | None = None # 施动者
target: Entity | None = None
player: int | None = None
amount: int = 0 # 伤害/治疗/抽牌数
fatal: bool = False
prevented: bool = False # 可被订阅者置位以取消
# 允许订阅者修改的可写字段
mutable_amount: int | None = None

4.2 触发器的声明式定义

@dataclass(frozen=True)
class TriggerDef:
on: Event
action: "Action"
condition: "Condition | None" = None
# 作用域:什么区域里的实体会响应这个触发
zones: frozenset[Zone] = frozenset({Zone.PLAY})
once: bool = False # 一次性(法术迸发、狂乱、超杀)
source_only: bool = False # 只响应自己身上的事件

有了 zones,一大类特殊卡就自然被支持了:

  • 正常随从的触发:zones={PLAY}
  • 亡语zones={PLAY},但订阅 DEATHsource_only=True
  • 《雏龙》类”在手牌中时”zones={HAND}
  • 《穆坦努斯》/《食腐土狼》类”在牌库中时”zones={DECK}
  • 奥秘zones={SECRET}
  • 任务zones={SECRET}(是的,任务放在奥秘区,所以《奥秘管理员》能抓任务——这曾经是个 bug)
  • 《开局时》(Start of Game)zones={DECK} + on=GAME_START
  • 《抽到时施放》(Casts When Drawn)zones={DECK} + on=DRAW + source_only=True

4.3 事件总线的实现

class EventBus:
def __init__(self, game):
self.game = game
# Event -> list[(entity, TriggerDef)],保持注册顺序
self._subs: dict[Event, list[tuple[Entity, TriggerDef]]] = defaultdict(list)
self.queue: deque[tuple[Entity, TriggerDef, EventCtx]] = deque()
self._depth = 0

def register_card(self, e: Entity):
for td in e.defn.triggers:
self._subs[td.on].append((e, td))
if e.defn.deathrattle:
td = TriggerDef(on=Event.DEATH, action=e.defn.deathrattle, source_only=True)
self._subs[Event.DEATH].append((e, td))

def unregister_all(self, e: Entity):
for lst in self._subs.values():
lst[:] = [(x, t) for (x, t) in lst if x is not e]

def emit(self, ctx: EventCtx):
"""收集匹配的触发器,按 ENTITY_ID 排序后入队。不立即执行!"""
matched = []
for (owner, td) in self._subs[ctx.kind]:
if owner[Tag.ZONE] not in td.zones:
continue
if td.source_only and ctx.entity is not owner:
continue
if owner.has(Tag.SILENCED) and not td.unsilenceable:
continue
if td.condition and not td.condition.eval(self.game, owner, ctx):
continue
matched.append((owner, td))

# ★ 关键:排序规则
matched.sort(key=lambda x: x[0][Tag.ENTITY_ID])
self.queue.extend((o, t, ctx) for (o, t) in matched)

4.4 触发顺序的三条铁律

这是炉石规则里最容易搞错的部分。经过社区多年测试,规则是:

铁律一:同一事件的多个触发器,按 ENTITY_ID 升序(即上场先后)依次结算。

不是从左到右,不是随机,是上场时间。所以两个《不稳定的食尸鬼》同时死亡时,先上场的那个先触发亡语。

铁律二:先手玩家的触发器优先于后手玩家?——不,这条是错的。

早期社区流传”己方优先”的说法,实测结果是:只按 ENTITY_ID,不分敌我。因为 ENTITY_ID 是全局递增的,所以自然地表现为”先放的先触发”。唯一例外是回合开始/结束这类玩家级事件——它们本身就只对一个玩家发生。

铁律三:触发过程中产生的新事件,追加到队列尾部,不插队。

这是队列(而非栈)语义的核心。举例:

场上有 A《不稳定的食尸鬼》(1/3,亡语:对所有随从造成 1 点伤害) 和 B《不稳定的食尸鬼》,都只剩 1 血。 你打出《暴风雪》对所有随从造成 2 点伤害。

结算过程:

1. 暴风雪造成伤害 → A、B 同时被标记死亡
2. [死亡结算阶段] 收集死亡:[A, B](按 ENTITY_ID)
3. 队列 = [A.亡语, B.亡语]
4. 执行 A.亡语 → 全场 1 点伤害 → 可能产生新死亡 C
5. 执行 B.亡语 → 全场 1 点伤害 → 可能产生新死亡 D
(注意:B 的亡语照常触发,即使 A 的亡语"又杀了它一次")
6. [死亡结算阶段] 收集新死亡 [C, D]
7. 队列 = [C.亡语, D.亡语]
8. ...直到不再产生死亡

如果是语义(MTG),第 4 步产生的死亡会打断第 5 步,先结算完 C 的亡语才轮到 B。行为完全不同。

炉石选队列的理由(Ben Brode 在开发者访谈中提过大意):队列的行为对玩家更好预测——”我看到的顺序就是执行的顺序”,而栈会产生”最后打出的最先生效”这种需要教学的反直觉。

4.5 队列的结算与深度限制

    def resolve(self):
"""结算触发队列直到清空。"""
self._depth += 1
if self._depth > 128:
# 无限循环保护。真实炉石也有——著名的"无限触发导致对局判和"
self.game.log("TRIGGER_DEPTH_EXCEEDED -> forcing draw")
self.queue.clear()
self.game.force_draw_game()
self._depth -= 1
return
while self.queue:
owner, td, ctx = self.queue.popleft()
# ★ 二次校验:入队时合法,出队时可能已经不合法了
if owner[Tag.ZONE] not in td.zones and td.on != Event.DEATH:
continue
if td.once and owner[Tag.TRIGGERED_ONCE]:
continue
if td.once:
owner[Tag.TRIGGERED_ONCE] = 1
self.game.run(td.action, source=owner, ctx=ctx)
self._depth -= 1

“二次校验”是真实引擎必须有的。场景:

你有《奥金尼灵魂祭司》(你的治疗改为伤害)和一个《北郡牧师》。 你对一个随从用了治疗。事件入队时两个触发器都匹配。 但第一个触发器把《北郡牧师》杀了 —— 第二个触发器出队时它已经不在场上。

真实炉石的行为是:已入队的触发依然会执行(对于亡语这类),但对于”场上随从的持续触发”则会被跳过。这个不一致性正是二次校验存在的原因,也是无数历史 bug 的来源。

4.6 一个真实的无限循环案例

2018 年有一个著名 bug:《米尔豪斯·法力风暴》+《紫罗兰教师》+ 某些卡的组合能触发无限召唤,导致服务器卡死。

修复方式(推断)就是上面的 _depth > 128 保护。现代炉石在检测到过深的触发链时会强制结束对局判和。你在设计任何触发系统时都应该加这个保护——因为卡池增长会让你不可能提前证明不存在环


第 5 章 触发队列 vs 栈:炉石的时序哲学

5.1 一个思想实验

假设你要设计一个卡牌游戏的效果解析系统。你有两个选择:

方案 A(栈):新产生的效果压栈,后进先出。 方案 B(队列):新产生的效果入队,先进先出。

看起来只是数据结构的区别,实际上它决定了游戏的整个交互模型:

队列
需要”优先权”系统吗必须。每个效果上栈后都要给双方响应机会不需要
对手能在你回合中打牌吗能(这是栈存在的意义)不能
单次操作能否原子化不能,中间要等待输入
网络往返次数每个效果 O(n) 次确认一次操作一次往返
移动端体验差(等待多)
复杂度高(规则手册 250 页)
策略深度低(但可以用别的方式补)

炉石选队列,根本原因是它是一个为移动端设计的、追求”一次操作一次动画”的游戏。栈会导致每次出牌都要等对手响应,在手机上是灾难。

5.2 队列带来的补偿设计:奥秘

但完全没有对手回合的互动,游戏会很无聊。炉石的解法是奥秘(Secret):把”对手的响应”从”实时决策”变成”预先承诺”。

奥秘的本质是:一个预先注册的、条件触发的、不需要询问所有者的触发器

SECRET_MIRROR_ENTITY = CardDef(
card_id="EX1_294", name="镜像实体", cost=3, card_type="SPELL",
keywords=frozenset({"SECRET"}),
triggers=(TriggerDef(
on=Event.MINION_PLAYED,
zones=frozenset({Zone.SECRET}),
condition=Cond.enemy_of_owner(),
action=Seq(
RevealSecret(),
Summon(copy_of=Ctx.entity, controller=Owner.SELF),
),
),),
)

这就完美绕开了”需要询问玩家”的问题:玩家在打出奥秘时就已经做完了决策

同样的设计哲学也出现在:

  • 亡语:不询问,自动执行
  • 《铜须》/《嘲讽》:改变攻击目标,不询问
  • 《法术反制》:自动反制,不询问

一条可以带走的设计经验:如果你的引擎需要在结算中途询问玩家,你就需要一个可挂起/可恢复的状态机(协程或者显式的状态保存)。炉石通过把所有决策前置,让整个结算变成一个可以同步跑完的纯函数。这在工程上的收益是巨大的:引擎可以被 AI 以每秒数万次的速度调用来做搜索

5.3 唯一的例外:发现(Discover)

《发现》是炉石里少数几个必须在结算中途停下来等玩家输入的机制。它是怎么实现的?

真实做法(推断):把 Discover 建模成一个”暂停点”,引擎把状态挂起,等客户端回传选择:

class DiscoverPending(Exception):
"""结算中断信号:需要玩家输入。"""
def __init__(self, options, continuation):
self.options = options
self.continuation = continuation


class Discover(Action):
def __init__(self, pool: "Selector", count: int = 3, then: "Action" = None):
self.pool, self.count, self.then = pool, count, then

def run(self, game, ctx):
candidates = self.pool.eval(game, ctx)
picks = game.rng.sample(candidates, min(self.count, len(candidates)))
if game.is_simulation:
# AI 模拟时不停顿,直接按策略选(或枚举所有分支)
chosen = game.sim_policy.pick(picks)
return self._apply(game, ctx, chosen)
# 真实对局:抛出中断,由外层保存 continuation
raise DiscoverPending(picks, lambda chosen: self._apply(game, ctx, chosen))

外层主循环捕获它,把 continuation 存进 game.pending,向客户端发一个 EntityChoices 包,等玩家回传后恢复执行。

这个设计的代价很大:它让”结算是原子的”这一性质被打破了。所以炉石对 Discover 有严格限制:

  • Discover 期间对手不能行动(游戏是回合制的,天然满足)
  • Discover 有超时(大约 25 秒),超时随机选
  • Discover 不能嵌套在 Discover 里(设计上避免)

如果你在自己的引擎里要支持这类机制,我强烈建议用 Python 的 generator / async 而不是异常,代码会干净得多:

async def play_card(self, card, target=None):
...
if card.defn.battlecry:
await self.run(card.defn.battlecry, source=card, target=target)

class Discover(Action):
async def run(self, game, ctx):
picks = game.rng.sample(self.pool.eval(game, ctx), 3)
chosen = await game.ask_choice(ctx.controller, picks) # 挂起点
await self._apply(game, ctx, chosen)

await game.ask_choice(...) 天然表达了”挂起等待”,而且 AI 模拟时可以给一个立即返回的 ask_choice 实现。这是我认为最值得从这份分析里借鉴的一条工程建议

5.4 “快照”:亡语必须记住什么

队列语义带来一个问题:触发器执行时,触发它的那个实体可能已经不存在了

亡语最典型:

《飞刀杂耍者》:”每当你召唤一个随从,就对一个随机敌人造成 1 点伤害。” 《恐狼前锋》:亡语——不,换个例子。 《蜘蛛坦克》:亡语:召唤一个 1/1 的机械。在哪个位置召唤?

答案是:在它死亡时所处的位置。但等亡语执行时,它已经不在场上了(ZONE = GRAVEYARD)。所以引擎必须在死亡瞬间记录快照:

@dataclass
class DeathSnapshot:
entity_id: int
card_id: str
controller: int
position: int # 死亡时的战场位置
attack: int # 死亡时的攻击力(《末日预言者》类需要)
health: int
race: str | None
enchantments: list # 死亡时的附魔(《恩佐斯》需要)
adjacent: tuple # 死亡时的相邻随从(快照,因为它们可能也死了)


def mark_death(game, e: Entity):
snap = DeathSnapshot(
entity_id=e[Tag.ENTITY_ID],
card_id=e[Tag.CARD_ID],
controller=e[Tag.CONTROLLER],
position=e[Tag.ZONE_POSITION],
attack=e[Tag.ATK], health=e[Tag.HEALTH],
race=e[Tag.CARDRACE],
enchantments=list(e._enchantments),
adjacent=game.neighbors(e),
)
e.death_snapshot = snap
e[Tag.TO_BE_DESTROYED] = 1

历史 bug 教训:早期炉石里,《蜘蛛坦克》的亡语在两个坦克同时死亡时会召唤到错误的位置,因为第一个亡语执行时改变了战场布局,第二个亡语用的是”当前位置”。快照修复了这个问题。

推广的设计原则:任何延迟执行的效果,都必须在注册时捕获它需要的所有上下文,而不是在执行时读取。这在事件驱动系统里是通用规律。

5.5 一个完整的时序推演

让我们完整推演一个复杂场面,这能把前面所有概念串起来。

场面

  • 你方战场(按上场顺序):[飞刀杂耍者(2/2), 不稳定的食尸鬼(1/1,已受伤), 血法师萨尔诺斯(1/1,法术伤害+1)]
  • 敌方战场:[鱼人猎潮者(2/1), 淡水鳄(2/3)]
  • 你打出《奥术射击》(造成 2 点伤害)目标:敌方《淡水鳄》

推演

T0  play_card(奥术射击, target=淡水鳄)
├─ compute_cost = 1,扣费
├─ emit(PRE_PLAY) → 队列空
├─ move(奥术射击 → SETASIDE)
├─ emit(PLAY_CARD) → 队列空
├─ emit(SPELL_CAST) → 队列空(对方无奥秘)
└─ run(spell)
└─ Damage(target=淡水鳄, amount=2 + spellpower)
├─ spellpower = 1(萨尔诺斯)→ amount = 3
├─ emit(PRE_DAMAGE, target=淡水鳄, amount=3)
│ → 无圣盾,无修饰
├─ 淡水鳄[DAMAGE] += 3 → 3/3 受 3 伤 → 死亡待定
└─ emit(DAMAGE, target=淡水鳄, amount=3, fatal=True)
→ 队列空(无"受到伤害时"触发器)

T1 resolve_queue() → 队列已空,无事发生

T2 process_deaths()
├─ 扫描全场,找 current_health <= 0 的实体:[淡水鳄]
├─ mark_death(淡水鳄) → 快照
├─ move(淡水鳄 → GRAVEYARD)
├─ aura_bus.mark_dirty() ← 场面变了,光环要重算
├─ emit(DEATH, entity=淡水鳄)
│ → 匹配:淡水鳄无亡语 → 队列空
└─ resolve_queue() → 空

T3 emit(AFTER_PLAY) → 队列空
T4 奥术射击 → GRAVEYARD

现在换一个复杂的:你打出《暴风雪》(对所有敌方随从造成 2 点伤害),但敌方是 [恐狼前锋(2/2,光环:相邻+1攻), 不稳定的食尸鬼(1/1), 蜘蛛坦克(1/4)]

T0  Damage(all_enemy_minions, 2)
★ 关键:目标列表在 Action 开始时就固定(快照),
即使中途有随从死亡,也不会重新计算目标
├─ Damage(恐狼前锋, 2) → 2/2 死亡待定
├─ Damage(食尸鬼, 2) → 1/1 死亡待定
└─ Damage(蜘蛛坦克, 2) → 1/4 受 2 伤,存活

T1 resolve_queue() → 空

T2 process_deaths() 第一轮
├─ 死亡列表 = [恐狼前锋, 食尸鬼] 按 ENTITY_ID 排序
├─ 两者同时快照、同时移入墓地
├─ aura_bus.mark_dirty() → 恐狼前锋的光环消失
│ → 蜘蛛坦克如果在它旁边,攻击力 -1(但生命值不变)
├─ emit(DEATH, 恐狼前锋) → 无亡语
├─ emit(DEATH, 食尸鬼) → 亡语入队
└─ resolve_queue()
└─ 食尸鬼亡语:对所有随从造成 1 点伤害
├─ 目标快照 = 当前所有随从(含己方!)
├─ 蜘蛛坦克 1/4 已受 2 伤 → 再受 1 伤 = 3 伤,存活
└─ 你方的飞刀杂耍者等也受伤

T3 process_deaths() 第二轮
└─ 检查是否有新死亡 → 若有则重复 T2

注意 T2 的顺序:所有死亡实体先全部移入墓地,然后才逐个触发亡语。这就是为什么”两个食尸鬼同时死”时,第二个食尸鬼的亡语依然会触发(它已经死了,但触发器已入队),而且它的亡语伤害不会再打到第一个食尸鬼(已在墓地)。


第 6 章 光环(Aura)与持续效果重算

6.1 光环 vs 附魔:一条清晰的分界

附魔(Enchantment)光环(Aura)
例子《王者祝福》+4/+4《暴风城勇士》其他随从+1/+1
存储方式附着实体,持久由源实体持续提供
源死亡后保留立即消失
沉默宿主移除不移除(光环来自别处)
沉默源消失
复制随从时取决于卡(大多不复制)新随从自动获得(因为光环重新计算)
实现写入 tag + 回滚记录每次脏标记后全量重算

判断标准:卡面文本是”获得(Gain)”→ 附魔;”拥有(Have)/ 其他随从……”→ 光环。

6.2 为什么必须全量重算

朴素的想法是增量维护:源上场时给所有随从 +1/+1,源离场时给所有随从 -1/-1。这是错的,因为:

  1. 源离场和新随从上场的顺序会导致漏加/漏减
  2. 光环有条件(《雷诺》类”如果你的牌库没有重复卡”),条件变化时要重算
  3. 光环可以嵌套(A 的光环让 B 的光环生效)
  4. 沉默、变形、心灵控制都会改变光环的适用范围

真实引擎的做法是脏标记 + 全量重算

# auras.py
@dataclass(frozen=True)
class AuraDef:
selector: "Selector" # 影响谁
tag: Tag # 改哪个标签
amount: int | "Callable" = 0 # 改多少(可以是动态的)
condition: "Condition|None" = None # 光环生效条件
include_self: bool = False


class AuraBus:
def __init__(self, game):
self.game = game
self._dirty = True
# entity_id -> {tag: delta}
self._deltas: dict[int, dict[Tag, int]] = {}

def mark_dirty(self):
self._dirty = True

def delta(self, e: Entity, tag: Tag) -> int:
if self._dirty:
self.recompute()
return self._deltas.get(e.id, {}).get(tag, 0)

def recompute(self):
self._dirty = False
old = self._deltas
new: dict[int, dict[Tag, int]] = defaultdict(lambda: defaultdict(int))

# 收集所有光环源(必须在 PLAY 区,且未被沉默)
sources = []
for pid in (0, 1):
for e in self.game.zones.get(pid, Zone.PLAY):
if e.has(Tag.SILENCED):
continue
for ad in e.defn.auras:
sources.append((e, ad))
# 武器、英雄技能、地标也能提供光环
for e in (self.game.player(pid).weapon, self.game.player(pid).hero_power):
if e:
for ad in e.defn.auras:
sources.append((e, ad))
# 手牌中生效的光环(如《元素》系"你的手牌中的X费用-1")
for pid in (0, 1):
for e in self.game.zones.get(pid, Zone.HAND):
for ad in getattr(e.defn, "hand_auras", ()):
sources.append((e, ad))

# ★ 稳定排序:保证重算结果与顺序无关(对于加法天然满足,
# 但对于 SET 类光环(如"你的随从攻击力变为 1")顺序有意义)
sources.sort(key=lambda x: (x[0][Tag.ENTITY_ID],))

for src, ad in sources:
if ad.condition and not ad.condition.eval(self.game, src, None):
continue
amount = ad.amount(self.game, src) if callable(ad.amount) else ad.amount
for tgt in ad.selector.eval(self.game, Ctx(source=src)):
if tgt is src and not ad.include_self:
continue
new[tgt.id][ad.tag] += amount

self._deltas = {k: dict(v) for k, v in new.items()}
self._apply_diff(old, self._deltas)

def _apply_diff(self, old, new):
"""把光环差值写回实体 tag,并广播给客户端。"""
touched = set(old) | set(new)
for eid in touched:
e = self.game.entities.get(eid)
if e is None:
continue
o, n = old.get(eid, {}), new.get(eid, {})
for tag in set(o) | set(n):
d = n.get(tag, 0) - o.get(tag, 0)
if d:
e.tags[tag] = e.tags.get(tag, 0) + d
self.game.record_tag_change(e, tag, e.tags[tag] - d, e.tags[tag])

6.3 重算时机:一个隐蔽的坑

光环什么时候重算?答案是:在任何可能改变光环适用范围的状态变化之后,且在下一次读取之前

具体的触发点(推断):

AURA_DIRTY_TRIGGERS = {
"实体进入或离开 PLAY 区",
"CONTROLLER 变化(心灵控制)",
"SILENCED 变化",
"CARDRACE / CARDTYPE 变化(变形)",
"ZONE_POSITION 变化(相邻类光环)",
"光环条件涉及的任何 tag 变化",
"手牌/牌库数量变化(如果有相关光环)",
}

最后一条最麻烦。《海巨人》的费用取决于场上随从数,《雷诺》的条件取决于牌库内容。朴素做法是每次任何 tag 变化都标脏,代价是性能。真实炉石采用的(推断)是分级脏标记:

class AuraBus:
DIRTY_BOARD = 1 # 场面结构变了
DIRTY_TAGS = 2 # 数值变了
DIRTY_HAND = 4
DIRTY_DECK = 8

def mark_dirty(self, flags=0xFF):
self._dirty |= flags

def recompute(self):
# 只重算受影响的光环
for src, ad in self._sources:
if not (ad.depends_on & self._dirty):
continue # 跳过
...

在 Python 迷你引擎里,直接全量重算是完全够用的(一局最多几百个实体,重算是微秒级)。但在真实产品里,光环重算是热点函数,尤其是酒馆战棋(14 个随从 × 若干光环 × 每次战斗多次重算)。

6.4 SET 类光环:顺序真的有意义

大部分光环是加法(+1/+1),加法可交换,顺序无所谓。但有些光环是赋值

  • 《巨型野猪》:”所有随从的攻击力变为 1″(历史卡)
  • 《宝库看守》类:”这个随从的攻击力等于它的生命值”
  • 《海巨人》的费用

赋值不可交换。如果 A 说”攻击力设为 1″,B 说”攻击力 +2″,结果取决于顺序。炉石的规则是:按 ENTITY_ID 顺序应用,赋值覆盖之前的所有修改

class AuraOp(IntEnum):
ADD = 0
SET = 1
MULTIPLY = 2

# 重算时分层:先应用所有 SET(按 ENTITY_ID),再 MULTIPLY,最后 ADD
def recompute_layered(self):
for op in (AuraOp.SET, AuraOp.MULTIPLY, AuraOp.ADD):
for src, ad in sorted(self._sources, key=lambda x: x[0][Tag.ENTITY_ID]):
if ad.op != op:
continue
...

这本质上是 MTG 的”分层系统”(Layer System)的简化版。MTG 有 7 层 + 时间戳 + 依赖性规则,炉石只有 3 层 + 时间戳。这是”降低复杂度换取可理解性”的又一个例子。


第 7 章 死亡结算:状态动作阶段

7.1 为什么死亡不是立即的

朴素实现:

def damage(target, amount):
target.health -= amount
if target.health <= 0:
target.die() # ❌ 立即死亡

这会立刻炸掉。场景:

《暴风雪》对所有敌方随从造成 2 点伤害。 敌方有 3 个 2 血随从,其中一个是《不稳定的食尸鬼》。

如果立即死亡,第一个随从死亡时就会触发亡语,亡语的伤害会打到”还没受到暴风雪伤害”的随从上。结果完全错误。

正确做法是两阶段

  1. 伤害阶段:只扣血,只标记 TO_BE_DESTROYED,不触发任何死亡逻辑
  2. 死亡结算阶段(Death Phase):统一收集所有待死实体,一次性移入墓地,然后按序触发亡语

这就是 MTG 的 State-Based Actions,炉石的简化版。

7.2 完整实现

def process_deaths(self):
"""死亡结算阶段。可能循环多次,直到不再产生新死亡。"""
rounds = 0
while True:
rounds += 1
if rounds > 64:
self.log("DEATH_LOOP_GUARD"); break

# ---- 步骤 1:收集 ----
dying = []
for pid in (0, 1):
for e in self.zones.get(pid, Zone.PLAY):
if e[Tag.CARDTYPE] == CardType.MINION:
if e.current_health <= 0 or e.has(Tag.TO_BE_DESTROYED):
dying.append(e)
# 武器耐久
w = self.player(pid).weapon
if w and (w[Tag.DURABILITY] <= 0 or w.has(Tag.TO_BE_DESTROYED)):
dying.append(w)
# 英雄
h = self.player(pid).hero
if h.current_health <= 0:
self.game_over_pending.add(pid)

if not dying:
break

# ---- 步骤 2:排序(★ 全局 ENTITY_ID 升序,不分敌我)----
dying.sort(key=lambda e: e[Tag.ENTITY_ID])

# ---- 步骤 3:全部快照并移入墓地(在触发任何亡语之前!)----
for e in dying:
mark_death(self, e)
self.emit_immediate(Event.PROPOSED_DEATH, entity=e) # 可被"免死"效果拦截

dying = [e for e in dying if not e.death_prevented]
for e in dying:
self.event_bus.unregister_nonDeath(e) # 注销非亡语触发器
self.zones.move(e, Zone.GRAVEYARD)
e[Tag.ZONE_POSITION] = 0

self.aura_bus.mark_dirty() # 场面变了

# ---- 步骤 4:按序触发死亡事件(亡语在此入队)----
for e in dying:
self.emit(Event.DEATH, entity=e, snapshot=e.death_snapshot)

# ---- 步骤 5:结算队列 ----
self.event_bus.resolve()

# ---- 步骤 6:复生(Reborn)在亡语之后 ----
for e in dying:
if e.has(Tag.REBORN) and not e.reborn_used:
self.summon_reborn(e)

# ---- 步骤 7:AFTER_DEATH(如《集市恶霸》"友方随从死亡后")----
for e in dying:
self.emit(Event.AFTER_DEATH, entity=e)
self.event_bus.resolve()

# 循环,检查是否有新死亡

# 游戏结束判定放在死亡循环之外
if self.game_over_pending:
self.end_game()

7.3 几个关键顺序的理由

为什么”全部移入墓地”要在”触发亡语”之前?

因为亡语可能引用”场上的随从”。如果 A 和 B 同时死,A 的亡语执行时 B 应该已经不在场上。这符合玩家直觉:”它们同时死了”。

为什么复生(Reborn)在亡语之后?

因为《复生》要在原位置召唤 1 血复制体,如果它先于亡语执行,亡语的”全场伤害”会打到复生体上。实测炉石的行为是复生体不会被同批次的亡语伤害打到,说明复生在亡语之后。

为什么游戏结束判定在最外层?

因为”双方英雄同时死亡”要判和。如果在循环内部判定,可能会因为亡语的执行顺序导致误判胜负。这也是为什么炉石里存在”平局”这个结果——2016 年之前甚至有 bug 导致某些同归于尽被判成一方胜利。

7.4 免死机制

有几张卡能”拦截”死亡:

  • 《恩佐斯的宝箱》(历史)
  • 《达尔坎》类”不会死亡”
  • 《不朽》/《圣光贤者》 类免疫

实现方式是 PROPOSED_DEATH 事件,允许订阅者置 death_prevented

class PreventDeath(Action):
def run(self, game, ctx):
ctx.entity.death_prevented = True
ctx.entity[Tag.TO_BE_DESTROYED] = 0
ctx.entity[Tag.DAMAGE] = 0 # 通常还要回血

注意 emit_immediate 而不是 emit —— 免死必须立即判定,不能入队,否则实体已经进墓地了。这是队列语义的一个必要例外。


第 8 章 攻击流程完整拆解

8.1 攻击不是”互相造成伤害”这么简单

一次攻击的完整流程有 11 步:

def attack(self, attacker: Entity, defender: Entity):
# ---- 1. 合法性 ----
if not self.can_attack(attacker):
raise IllegalMove("attacker cannot attack")
if not self.is_legal_attack_target(attacker, defender):
raise IllegalMove("illegal target")

# ---- 2. 宣言:允许改变目标 ----
ctx = EventCtx(kind=Event.PROPOSED_ATTACK,
source=attacker, target=defender)
self.emit_immediate(ctx) # 《铜须》类改目标、奥秘《误导》在此
defender = ctx.target # ★ 目标可能被改了
if ctx.prevented:
return

# ---- 3. 潜行消失 ----
if attacker.has(Tag.STEALTH):
attacker[Tag.STEALTH] = 0

# ---- 4. 攻击确认事件(《砰砰法师》、《狂野炎术师》)----
self.emit(Event.ATTACK, source=attacker, target=defender)
self.event_bus.resolve()
# ★ 结算后重新校验:《砰砰法师》可能把防御者炸死了
if not self.still_valid_attack(attacker, defender):
self._finish_attack(attacker)
return

# ---- 5. 快照攻击力(★ 关键)----
atk_power = attacker[Tag.ATK]
def_power = defender[Tag.ATK]

# ---- 6. 同时造成伤害 ----
if def_power > 0:
self.damage(attacker, def_power, source=defender)
if atk_power > 0:
self.damage(defender, atk_power, source=attacker)

# ---- 7. 剧毒 ----
if attacker.has(Tag.POISONOUS) and atk_power > 0 and defender.is_minion:
if not defender.divine_shield_absorbed_this_attack:
defender[Tag.TO_BE_DESTROYED] = 1
if defender.has(Tag.POISONOUS) and def_power > 0 and attacker.is_minion:
if not attacker.divine_shield_absorbed_this_attack:
attacker[Tag.TO_BE_DESTROYED] = 1

# ---- 8. 吸血 ----
if attacker.has(Tag.LIFESTEAL) and atk_power > 0:
self.heal(self.player(attacker[Tag.CONTROLLER]).hero, atk_power)

# ---- 9. 武器耐久 ----
if attacker.is_hero:
w = self.player(attacker[Tag.CONTROLLER]).weapon
if w and not attacker.has(Tag.IMMUNE_WHILE_ATTACKING):
w[Tag.DURABILITY] -= 1

# ---- 10. 计数与后续事件 ----
attacker[Tag.NUM_ATTACKS] += 1
self.emit(Event.AFTER_ATTACK, source=attacker, target=defender)

# ---- 11. 结算 ----
self.event_bus.resolve()
self.process_deaths()

8.2 第 5 步的快照为什么关键

考虑:

你的 3/2 攻击对方的 2/5《恐狼前锋》,对方场上有《暴风城勇士》给它 +1/+1,实际是 3/6。 你的随从有《剧毒》。

如果不快照,会发生:

  1. 你的随从对它造成 3 伤 → 它变成 3/3
  2. 它对你造成伤害时读 ATK → 还是 3(没变)

看起来没问题。但换个场景:

你的 4/4 攻击对方的《不稳定的食尸鬼》,你场上还有一个 1/1。 食尸鬼是 1/1。

  1. 你的 4/4 打死食尸鬼
  2. 食尸鬼对你造成 1 点伤害

如果先结算死亡再让防御者反击,食尸鬼就打不出这 1 点伤害了。炉石规则是”同时”,所以必须先快照双方攻击力,然后一起造成伤害,最后统一结算死亡。

更极端的例子(真实测试过的):

你的《剧毒》随从 1/1 攻击对方 10/10。 双方同归于尽——你的 1/1 被打死,对方被剧毒杀死。

如果按顺序结算,你的 1/1 先死,剧毒就不生效了。快照保证了同时性。

8.3 圣盾与剧毒的交互

第 7 步里的 divine_shield_absorbed_this_attack 是个细节。规则:

圣盾吸收了伤害 → 剧毒不生效。

因为剧毒的规则文本是”任何受到该随从伤害的随从”,圣盾意味着没有受到伤害。

def damage(self, target, amount, source=None):
if amount <= 0:
return 0
if target.has(Tag.IMMUNE):
self.emit(Event.DAMAGE_PREVENTED, target=target, source=source)
return 0

ctx = EventCtx(kind=Event.PRE_DAMAGE, target=target, source=source,
amount=amount, mutable_amount=amount)
self.emit_immediate(ctx)
amount = max(0, ctx.mutable_amount)
if amount == 0:
return 0

# ★ 圣盾在 PRE_DAMAGE 之后、实际扣血之前消耗
if target.has(Tag.DIVINE_SHIELD):
target[Tag.DIVINE_SHIELD] = 0
target.divine_shield_absorbed_this_attack = True
self.emit(Event.DIVINE_SHIELD_BROKEN, target=target)
return 0 # ← 返回 0,所以剧毒/吸血都不生效

# 英雄先扣护甲
if target.is_hero:
armor = min(target[Tag.ARMOR], amount)
target[Tag.ARMOR] -= armor
amount -= armor
if amount == 0:
return 0

target[Tag.DAMAGE] += amount
fatal = target.current_health <= 0
self.emit(Event.DAMAGE, target=target, source=source,
amount=amount, fatal=fatal)

# 吸血:注意是"伤害来源的控制者"回血,不是攻击者
if source is not None and source.has(Tag.LIFESTEAL):
self.heal(self.player(source[Tag.CONTROLLER]).hero, amount)

return amount

damage() 返回实际造成的伤害,这个返回值被大量卡牌使用:

  • 《超杀》:if actual_damage > target_health_before
  • 《荣誉击杀》:if actual_damage == target_health_before
  • 《吸血》:回血量 = 实际伤害
  • 《狂乱》:if actual_damage > 0 and survived

一个返回值支撑了四个关键词——这就是好抽象的价值。

8.4 嘲讽的目标合法性

def is_legal_attack_target(self, attacker, defender) -> bool:
if defender[Tag.CONTROLLER] == attacker[Tag.CONTROLLER]:
return False # 不能打自己人(除个别卡)
if defender.has(Tag.STEALTH):
return False # 潜行不可被攻击
if defender.has(Tag.IMMUNE):
return False
if attacker.has(Tag.RUSH) and attacker[Tag.NUM_TURNS_IN_PLAY] == 0:
if defender.is_hero:
return False # 突袭首回合不能打脸

# 嘲讽检查:注意潜行的嘲讽随从不提供嘲讽保护!
enemy_side = self.zones.get(1 - attacker[Tag.CONTROLLER], Zone.PLAY)
taunts = [m for m in enemy_side
if m.has(Tag.TAUNT) and not m.has(Tag.STEALTH) and not m.has(Tag.IMMUNE)]
if taunts and defender not in taunts:
return False
return True

潜行的嘲讽随从不提供嘲讽保护——这是个经典细节。因为潜行的随从”不能被攻击”,如果它同时提供嘲讽保护,就会形成”什么都不能打”的死锁。


第 9 章 目标选择:合法性的层层过滤

9.1 三种”不可被选中”

炉石有三个容易混淆的概念:

关键词效果例子
潜行(Stealth)不能被敌方攻击,也不能被敌方法术/技能指向《豺狼人斥候》
不可被选中(Elusive)不能被任何法术/英雄技能指向(包括己方!),但可以被攻击《法力浮龙》
免疫(Immune)不受任何伤害,也不能被攻击/指向《冰箱法》
def is_legal_target(self, card_or_source, target) -> bool:
if target is None:
return card_or_source.defn.targeting is None
sel = card_or_source.defn.targeting
if sel is None:
return False
# 1. 基础筛选(Selector 定义的,如"一个友方野兽")
if target not in sel.eval(self.game, Ctx(source=card_or_source)):
return False
# 2. 通用不可选中规则
if target.has(Tag.ELUSIVE):
return False # 敌我都不行
if target.has(Tag.IMMUNE):
return False
if target.has(Tag.UNTOUCHABLE):
return False
if target.has(Tag.STEALTH) and \
target[Tag.CONTROLLER] != card_or_source[Tag.CONTROLLER]:
return False # 只对敌方潜行有效
if target.has(Tag.DORMANT):
return False
return True

9.2 “随机目标”与”选择目标”的区别

一条重要规则:不可被选中(Elusive)只挡”选择”,不挡”随机”

  • 《法力浮龙》不能被《火球术》指向
  • 但《暴风雪》(AOE,无目标)照打
  • 《飞刀杂耍者》的随机伤害照打
class TargetMode(IntEnum):
CHOSEN = 0 # 玩家指向 → 受 Elusive/Stealth 限制
RANDOM = 1 # 引擎随机 → 不受限制
ALL = 2 # 全体 → 不受限制
ADJACENT = 3 # 相邻 → 不受限制

这个区分必须在 Selector 层面表达,而不是在每张卡里判断。我们在第三部分会看到 Selector DSL 如何编码它。

9.3 战吼无合法目标时会怎样

规则:如果战吼需要目标但场上没有合法目标,战吼不会触发(但随从照常上场)

if script and card.defn.targeting is not None:
legal = [t for t in card.defn.targeting.eval(game, ctx)
if game.is_legal_target(card, t)]
if not legal:
pass # 战吼跳过,随从正常上场
else:
assert target in legal
game.run(script, source=card, target=target)

这条规则有一个策略性后果:《暗影步》类卡可以”重置”一个已经用过战吼的随从,而如果场上没有目标,你可以把《火车王》这类”战吼有代价”的随从当成无代价的身材。

9.4 客户端与服务器的目标校验

推断:客户端会做一次目标高亮(UX),服务器必须独立校验一次。因为客户端不可信,且客户端的状态可能落后于服务器(比如你正在拖拽时对手的奥秘触发了)。

真实炉石在这里有一个可观察的行为:偶尔你会看到”目标被拒绝,卡牌弹回手里”的动画——这就是服务器校验失败。


第三部分 数据驱动:一万张卡如何不写一万个 if


第 10 章 从硬编码到 DSL:三次演化

10.1 第零阶段:硬编码(不可行)

def play_card(card, target):
if card.name == "火球术":
deal_damage(target, 6)
elif card.name == "奥术射击":
deal_damage(target, 2)
elif card.name == "冰枪术":
deal_damage(target, 4)
freeze(target)
elif ... # ×6000

问题不只是长。真正的问题是:

  1. 新卡必须改引擎代码 → 必须发客户端版本 → 无法热更新
  2. 无法做平衡性调整(改数值要改代码)
  3. AI 无法理解卡牌(只能靠模拟,不能做启发式估值)
  4. 无法自动生成卡牌文本(本地化噩梦)
  5. 无法静态分析(”哪些卡会造成伤害?”这个查询要靠 grep)

10.2 第一阶段:回调函数(半可行)

CARDS = {
"CS2_029": CardDef(name="火球术", cost=4,
effect=lambda g, src, tgt: g.damage(tgt, 6, src)),
"EX1_277": CardDef(name="奥术冲击", cost=1,
effect=lambda g, src, tgt: g.damage(tgt, 1, src)),
}

好一些了。卡牌变成了数据结构的字段。但仍有致命问题:lambda 是不透明的

  • 你不能问”这张卡造成多少伤害”,只能执行它
  • AI 只能靠模拟,无法预估
  • 不能序列化(无法热更新)
  • 不能自动生成文本

真实炉石并不是这样做的。社区从 CardDefs.xml 里看到的是结构化的效果描述,说明暴雪走的是第二阶段。

10.3 第二阶段:Action 树(正确答案)

把效果表达成可检查的数据结构

FIREBALL = CardDef(
card_id="CS2_029", name="火球术", cost=4, card_type="SPELL",
spell_school="FIRE",
targeting=Sel.any_character(),
spell=Damage(Ctx.TARGET, 6),
)

FROSTBOLT = CardDef(
card_id="CS2_024", name="寒冰箭", cost=2, card_type="SPELL",
spell_school="FROST",
targeting=Sel.any_character(),
spell=Seq(
Damage(Ctx.TARGET, 3),
Freeze(Ctx.TARGET),
),
)

Damage(Ctx.TARGET, 6) 是一个对象,不是函数。你可以:

>>> FIREBALL.spell
Damage(target=Ctx.TARGET, amount=6)
>>> FIREBALL.spell.estimated_damage()
6
>>> FIREBALL.spell.to_json()
{"op": "Damage", "target": "TARGET", "amount": 6}
>>> render_text(FIREBALL.spell, locale="zh-CN")
"造成 $6 点伤害。"

这四个能力——估值、序列化、文本生成、静态分析——正是硬编码给不了的。

10.4 炉石实际用的是什么

推断(基于社区对 CardDefs.xml 与游戏内 Power 定义的解包分析):

炉石的卡牌定义包含一个 <Power> 节点,引用一个 PowerDefinition,里面是一串”效果”,每个效果有:

  • EffectType(Damage / Heal / Summon / Draw / Buff / …)
  • TargetSelector(一个筛选器 ID)
  • Amount(可以是常数,也可以是”运行时计算”的引用)
  • Conditions

也就是说,炉石的卡牌效果确实是数据驱动的 Action 树,只是用的是 C# 的类层次而非 Python。少数极其特殊的卡(如《尤格-萨隆》、《魔法契约》)会 fallback 到硬编码的 SpecialEffect,这在任何数据驱动系统里都是必要的逃生舱。

设计原则:DSL 覆盖 95% 的卡,剩下 5% 允许写代码。 追求 100% 覆盖会让 DSL 复杂到不如直接写代码。


第 11 章 Selector:筛选器语言

11.1 为什么筛选器要独立成一层

看这几张卡的目标描述:

  • “一个随从”
  • “一个友方野兽”
  • “所有敌方随从”
  • “所有其他友方随从”
  • “随机一个敌方随从”
  • “攻击力最高的敌方随从”
  • “你的牌库中费用最低的法术”
  • “相邻的随从”
  • “手牌最右侧的卡”

它们出现在多个位置:目标筛选、AOE 范围、光环范围、抽牌条件、亡语目标……如果每处都写一遍,就会有大量重复。所以 Selector 必须是一等公民,可组合、可复用。

11.2 Selector 的代数结构

Selector 是一个从”上下文”到”实体列表”的函数,但我们把它表达成可组合的对象

# selectors.py
class Selector:
def eval(self, game, ctx) -> list[Entity]:
raise NotImplementedError

# ---- 组合子 ----
def __add__(self, other): return Union(self, other) # 并集
def __sub__(self, other): return Difference(self, other) # 差集
def __and__(self, other): return Intersect(self, other)
def where(self, cond): return Filtered(self, cond)
def random(self, n=1): return RandomPick(self, n)
def top(self, n, key): return TopN(self, n, key)
def sorted_by(self, key): return SortedBy(self, key)
def limit(self, n): return Limit(self, n)


# ---- 原子 Selector ----
class AllMinions(Selector):
def eval(self, game, ctx):
return [e for pid in (0, 1) for e in game.zones.get(pid, Zone.PLAY)
if e[Tag.CARDTYPE] == CardType.MINION]

class Friendly(Selector):
"""把任意 selector 限制在友方"""
def __init__(self, inner): self.inner = inner
def eval(self, game, ctx):
me = ctx.controller
return [e for e in self.inner.eval(game, ctx) if e[Tag.CONTROLLER] == me]

class Enemy(Selector):
def __init__(self, inner): self.inner = inner
def eval(self, game, ctx):
me = ctx.controller
return [e for e in self.inner.eval(game, ctx) if e[Tag.CONTROLLER] != me]

class Self_(Selector):
def eval(self, game, ctx): return [ctx.source]

class TargetSel(Selector):
def eval(self, game, ctx): return [ctx.target] if ctx.target else []

class Adjacent(Selector):
def __init__(self, of=None): self.of = of or Self_()
def eval(self, game, ctx):
out = []
for e in self.of.eval(game, ctx):
out.extend(game.neighbors(e))
return out

class InZone(Selector):
def __init__(self, zone, of_player="SELF"):
self.zone, self.of_player = zone, of_player
def eval(self, game, ctx):
pid = ctx.controller if self.of_player == "SELF" else 1 - ctx.controller
return list(game.zones.get(pid, self.zone))

11.3 Condition:谓词语言

class Condition:
def eval(self, game, e, ctx) -> bool: raise NotImplementedError
def __and__(self, o): return AndCond(self, o)
def __or__(self, o): return OrCond(self, o)
def __invert__(self): return NotCond(self)

class HasTag(Condition):
def __init__(self, tag, value=None): self.tag, self.value = tag, value
def eval(self, game, e, ctx):
return bool(e[self.tag]) if self.value is None else e[self.tag] == self.value

class IsRace(Condition):
def __init__(self, race): self.race = race
def eval(self, game, e, ctx):
# ★ "全部"类型(如《变形兽》)匹配任何 race 查询
return e[Tag.CARDRACE] in (self.race, "ALL")

class CostAtMost(Condition):
def __init__(self, n): self.n = n
def eval(self, game, e, ctx): return game.compute_cost(e) <= self.n

class AttackAtLeast(Condition):
def __init__(self, n): self.n = n
def eval(self, game, e, ctx): return e[Tag.ATK] >= self.n

11.4 组合出真实卡牌的筛选器

有了这些积木,实际卡牌的筛选就是一行:

class Sel:
"""常用筛选器的命名空间,纯粹是可读性糖。"""
ALL_MINIONS = AllMinions()
FRIENDLY_MINIONS = Friendly(AllMinions())
ENEMY_MINIONS = Enemy(AllMinions())
OTHER_FRIENDLY = Friendly(AllMinions()) - Self_()
ALL_CHARACTERS = AllMinions() + AllHeroes()
TARGET = TargetSel()
SELF = Self_()

@staticmethod
def friendly_beasts():
return Friendly(AllMinions()).where(IsRace("BEAST"))

@staticmethod
def random_enemy_minion(n=1):
return Enemy(AllMinions()).random(n)

@staticmethod
def highest_attack_enemy():
return Enemy(AllMinions()).top(1, key=lambda e: e[Tag.ATK])

真实卡牌:

# 《野性印记》:使一个随从获得 +2/+2 和嘲讽
MARK_OF_NATURE = CardDef(
card_id="EX1_155", name="野性印记", cost=3, card_type="SPELL",
choose_one=("EX1_155a", "EX1_155b"),
targeting=Sel.ALL_MINIONS,
)

# 《暴风雪》:对所有敌方随从造成 2 点伤害并冻结
BLIZZARD = CardDef(
card_id="CS2_028", name="暴风雪", cost=6, card_type="SPELL",
spell_school="FROST",
spell=Seq(
Damage(Sel.ENEMY_MINIONS, 2),
Freeze(Sel.ENEMY_MINIONS),
),
)

# 《暗影狂乱》:获得一个攻击力小于等于 3 的敌方随从的控制权,直到回合结束
SHADOW_MADNESS = CardDef(
card_id="EX1_334", name="暗影狂乱", cost=4, card_type="SPELL",
spell_school="SHADOW",
targeting=Sel.ENEMY_MINIONS.where(AttackAtMost(3)),
spell=Seq(
TakeControl(Ctx.TARGET, until=Until.END_OF_TURN),
SetTag(Ctx.TARGET, Tag.EXHAUSTED, 0), # 可以立刻攻击
),
)

11.5 排序与随机的确定性问题

RandomPickTopN 有一个隐蔽的陷阱:平局怎么办?

“攻击力最高的敌方随从”——如果有两个 5 攻随从怎么办?炉石的规则是随机选一个。所以:

class TopN(Selector):
def __init__(self, inner, n, key):
self.inner, self.n, self.key = inner, n, key
def eval(self, game, ctx):
pool = self.inner.eval(game, ctx)
if not pool: return []
best = max(self.key(e) for e in pool)
tied = [e for e in pool if self.key(e) == best]
if len(tied) <= self.n:
return tied
return game.rng.sample(tied, self.n) # ★ 必须走游戏 RNG,不能用全局 random

game.rng 而不是 random 是绝对不能妥协的。所有随机必须来自一个 seeded 的、只被游戏逻辑消费的 RNG,否则:

  • 回放会不一致
  • AI 模拟会污染真实对局的随机序列
  • 无法复现 bug

第六部分会详细展开确定性随机。

11.6 筛选器的求值时机:早绑定 vs 晚绑定

一个微妙的问题:Damage(Sel.ENEMY_MINIONS, 2) 在什么时候求值 ENEMY_MINIONS

答案:在 Action 开始执行的瞬间,一次性求值,然后遍历这个快照。

为什么不是边遍历边求值?考虑:

《暴风雪》对所有敌方随从造成 2 点伤害。 敌方有一个《不稳定的食尸鬼》和一个 3/3。

如果晚绑定(每次迭代重新求值),食尸鬼死亡后列表会变,可能漏打或重复打。早绑定保证了”目标在效果开始时确定”,符合玩家直觉。

class Damage(Action):
def __init__(self, sel, amount):
self.sel, self.amount = sel, amount
def run(self, game, ctx):
targets = list(self.sel.eval(game, ctx)) # ★ 快照
amt = resolve_amount(self.amount, game, ctx)
if game.is_spell_context(ctx):
amt += game.spellpower(ctx.controller) # 法术伤害加成
for t in targets:
if t[Tag.ZONE] != Zone.PLAY: # 出发时还在,现在可能不在了
continue
game.damage(t, amt, source=ctx.source)

注意里面还是有个 if t[Tag.ZONE] != Zone.PLAY: continue。这不矛盾:目标列表是快照,但已经离场的目标要跳过。这正是真实炉石的行为。


第 12 章 Action:效果语言

12.1 Action 基类与执行上下文

# actions.py
@dataclass
class Ctx:
"""Action 执行上下文,沿着 Action 树传递。"""
source: Entity # 效果来源(卡牌本体)
controller: int
target: Entity | None = None
event: EventCtx | None = None # 触发型效果的事件上下文
stored: dict = field(default_factory=dict) # Action 之间传值
choose: int = 0 # 抉择选项

TARGET = "__TARGET__" # 占位符,供 Selector 引用


class Action:
def run(self, game, ctx: Ctx): raise NotImplementedError
def to_json(self) -> dict: ...
def describe(self, locale="zh-CN") -> str: ...

12.2 控制流 Action

class Seq(Action):
"""顺序执行。这是最常用的组合子。"""
def __init__(self, *actions): self.actions = actions
def run(self, game, ctx):
for a in self.actions:
a.run(game, ctx)

class If(Action):
def __init__(self, cond, then, otherwise=None):
self.cond, self.then, self.otherwise = cond, then, otherwise
def run(self, game, ctx):
if self.cond.eval(game, ctx.source, ctx):
self.then.run(game, ctx)
elif self.otherwise:
self.otherwise.run(game, ctx)

class Repeat(Action):
def __init__(self, n, action): self.n, self.action = n, action
def run(self, game, ctx):
for _ in range(resolve_amount(self.n, game, ctx)):
self.action.run(game, ctx)

class ForEach(Action):
"""对筛选器的每个结果执行一次,把当前项放进 ctx.target"""
def __init__(self, sel, action): self.sel, self.action = sel, action
def run(self, game, ctx):
for e in list(self.sel.eval(game, ctx)):
sub = replace(ctx, target=e)
self.action.run(game, sub)

class Choose(Action):
"""抉择:根据 ctx.choose 分支"""
def __init__(self, *options): self.options = options
def run(self, game, ctx):
if ctx.choose == -1: # 《弗洛普的槌子》类"两种效果都触发"
for o in self.options: o.run(game, ctx)
else:
self.options[ctx.choose].run(game, ctx)

12.3 原语 Action(约 40 个覆盖 95% 的卡)

# ---- 伤害与治疗 ----
class Damage(Action): ...
class Heal(Action): ...
class SetHealth(Action): ...
class Destroy(Action):
"""直接消灭,无视生命值。剧毒、《暗影词术:死亡》等"""
def __init__(self, sel): self.sel = sel
def run(self, game, ctx):
for e in self.sel.eval(game, ctx):
e[Tag.TO_BE_DESTROYED] = 1 # 只标记,等死亡结算阶段

# ---- 牌与区域 ----
class Draw(Action): ...
class Discard(Action): ...
class Mill(Action): ... # 弃掉牌库顶
class AddToHand(Action): ... # 生成一张牌到手
class ShuffleIntoDeck(Action): ...
class ReturnToHand(Action): ... # 《暗影步》
class Discover(Action): ...
class Dredge(Action): ... # 打捞:看底三张
class Tutor(Action): ... # 从牌库检索特定卡

# ---- 战场 ----
class Summon(Action): ...
class Transform(Action): ... # 变形
class TakeControl(Action): ... # 心灵控制
class Silence(Action): ...
class Bounce(Action): ...

# ---- 修饰 ----
class Buff(Action):
"""附加一个附魔"""
def __init__(self, sel, enchant_id): ...
class SetTag(Action): ...
class GiveKeyword(Action): ... # 给予嘲讽/圣盾/风怒等
class Freeze(Action): ...

# ---- 资源 ----
class GainMana(Action): ... # 《硬币》
class GainEmptyMana(Action): ... # 《野性成长》
class Overload(Action): ...
class GainArmor(Action): ...
class GainCorpses(Action): ... # 尸块

# ---- 元 ----
class TriggerDeathrattle(Action): ... # 《恩佐斯》《跳蚤商人》
class CopyCard(Action): ...
class SwapWith(Action): ...
class ChangeHeroPower(Action): ...
class EquipWeapon(Action): ...

12.4 数值的间接引用:Amount 表达式

很多卡的数值不是常数:

  • “造成等同于你护甲值的伤害”
  • “抽牌,数量等于你的随从数”
  • “获得 +1/+1,每有一张手牌”

所以 amount 必须允许是一个表达式:

class Amount:
def eval(self, game, ctx) -> int: raise NotImplementedError
def __add__(self, o): return AmountAdd(self, o)
def __mul__(self, o): return AmountMul(self, o)

class Const(Amount):
def __init__(self, v): self.v = v
def eval(self, g, c): return self.v

class CountOf(Amount):
def __init__(self, sel): self.sel = sel
def eval(self, g, c): return len(self.sel.eval(g, c))

class TagOf(Amount):
def __init__(self, sel, tag): self.sel, self.tag = sel, tag
def eval(self, g, c):
es = self.sel.eval(g, c)
return es[0][self.tag] if es else 0

def resolve_amount(a, game, ctx) -> int:
return a.eval(game, ctx) if isinstance(a, Amount) else int(a)

于是:

# 《背刺》:对一个未受伤害的随从造成 2 点伤害
BACKSTAB = CardDef(
card_id="CS2_072", cost=0, card_type="SPELL",
targeting=Sel.ALL_MINIONS.where(~HasTag(Tag.DAMAGE)),
spell=Damage(Ctx.TARGET, 2),
)

# 《末日守卫》——不,用《炎爆术》:造成 10 点伤害
PYROBLAST = CardDef(card_id="EX1_279", cost=10, card_type="SPELL",
targeting=Sel.ALL_CHARACTERS, spell=Damage(Ctx.TARGET, 10))

# 《暴怒的狼人》:嘲讽,风怒?不,用《精灵龙》——
# 用一个真正需要 Amount 的:《毁灭之刃》
# "造成等同于你武器攻击力的伤害"
BLADE_FURY = CardDef(
card_id="AT_033", cost=2, card_type="SPELL",
spell=Seq(
Damage(Sel.ENEMY_MINIONS, TagOf(Sel.MY_WEAPON, Tag.ATK)),
DestroyWeapon(),
),
)

# 《北郡牧师》类:抽牌数 = 友方随从数
DRAW_PER_MINION = Draw(CountOf(Sel.FRIENDLY_MINIONS))

12.5 一个非平凡的完整卡牌定义

用一张有多个关键词交互的卡来验证 DSL 的表达力。

《砰砰博士》(战吼:召唤两个 1/1 的機械,其亡语为对随机敌人造成 1 点伤害)——用一张更复杂的:

《拉格纳罗斯》:不能攻击。在你的回合结束时,对一个随机敌人造成 8 点伤害。

RAGNAROS = CardDef(
card_id="EX1_298", name="炎魔之王拉格纳罗斯",
cost=8, card_type="MINION", attack=8, health=8, race="ELEMENTAL",
keywords=frozenset({"CANNOT_ATTACK"}),
triggers=(
TriggerDef(
on=Event.TURN_END,
condition=Cond.my_turn(),
action=Damage(Sel.ENEMY_CHARACTERS.random(1), 8),
),
),
)

《希尔瓦娜斯·风行者》:亡语——随机获得一个敌方随从的控制权。

SYLVANAS = CardDef(
card_id="EX1_016", name="希尔瓦娜斯·风行者",
cost=6, card_type="MINION", attack=5, health=5,
deathrattle=TakeControl(Sel.ENEMY_MINIONS.random(1)),
)

《克苏恩》(需要读取一个玩家级计数器):

CTHUN = CardDef(
card_id="OG_280", name="克苏恩", cost=10, card_type="MINION",
attack=6, health=6, # 基础值,实际由附魔累加
battlecry=Damage(Sel.ENEMY_CHARACTERS.random(TagOf(Sel.SELF, Tag.ATK)), 1),
# 实际实现:造成"攻击力"次 1 点随机伤害
)

《尤格-萨隆》(DSL 无法表达,必须逃生舱):

YOGG = CardDef(
card_id="OG_134", name="尤格-萨隆", cost=10, card_type="MINION",
attack=7, health=5,
battlecry=SpecialEffect("YOGG_SARON_CAST_RANDOM_SPELLS"),
)

@special_effect("YOGG_SARON_CAST_RANDOM_SPELLS")
def yogg(game, ctx):
n = game.player(ctx.controller).spells_cast_this_game
for _ in range(n):
if ctx.source[Tag.ZONE] != Zone.PLAY:
break # 尤格死了就停(这是 2016 年的著名 nerf)
spell = game.rng.choice(game.all_castable_spells())
target = game.random_legal_target(spell)
game.cast_spell_from_effect(spell, target, ctx.controller)

这就是逃生舱的正确用法:不去扭曲 DSL 来表达一张卡,而是承认它特殊,给它写代码。真实炉石里这样的卡不超过 100 张。

12.6 自动生成卡牌文本

DSL 的一个副产品:可以从 Action 树生成本地化文本。

class Damage(Action):
def describe(self, locale="zh-CN"):
t = self.sel.describe(locale)
if locale == "zh-CN":
return f"对{t}造成 ${self.amount} 点伤害。"
return f"Deal ${self.amount} damage to {t}."

>>> BLIZZARD.spell.describe()
"对所有敌方随从造成 $2 点伤害。冻结所有敌方随从。"

真实炉石没有完全这样做(卡面文本是手写的,因为要考虑文学性),但它确实用 DSL 来做动态数值高亮:卡面上的 $2 会在有法术伤害加成时显示为绿色的 3。这个功能要求引擎能在不执行效果的前提下算出最终数值——这只有数据驱动才做得到。


第 13 章 附魔(Enchantment)系统

13.1 附魔是实体

一个反直觉但正确的设计:附魔本身是一个实体,有自己的 ENTITY_ID,存在于一个特殊的”附着”关系里。

ENCH_BLESSING_OF_KINGS = CardDef(
card_id="CS2_092e", name="王者祝福", card_type="ENCHANTMENT",
enchant_tags={Tag.ATK: +4, Tag.HEALTH: +4},
)

def apply_enchantment(game, host: Entity, ench_id: str, duration=None):
e = game.create_entity(ench_id, host[Tag.CONTROLLER], zone=Zone.SETASIDE)
e[Tag.ATTACHED] = host[Tag.ENTITY_ID]
host._enchantments.append(e)
# 写入式:直接改宿主 tag
for tag, delta in e.defn.enchant_tags.items():
host[tag] = host[tag] + delta
if e.defn.triggers:
game.event_bus.register_card(e) # 附魔可以带触发器!
if duration:
e.expires_at = game.turn + duration
return e

def remove_enchantment(game, e: Entity):
host = game.entities[e[Tag.ATTACHED]]
for tag, delta in e.defn.enchant_tags.items():
host[tag] = host[tag] - delta # 回滚
host._enchantments.remove(e)
game.event_bus.unregister_all(e)
game.destroy_entity(e)

为什么附魔要是实体而不是一个 dict?

因为附魔需要:

  1. 携带触发器:《不稳定的传送门》的折扣、《暗影狂乱》的”回合结束时归还”、《回响》的”回合结束时消失”
  2. 被独立引用:《恩佐斯,深海领主》要复制”随从死亡时的附魔状态”
  3. 被网络同步:客户端要显示附魔图标和 tooltip
  4. 有来源:《沉默》要区分”这个 buff 是自带的还是被加的”

第 2 点特别重要。《暗影狂乱》的实现:

ENCH_SHADOW_MADNESS = CardDef(
card_id="EX1_334e", card_type="ENCHANTMENT",
triggers=(TriggerDef(
on=Event.TURN_END,
zones=frozenset({Zone.SETASIDE}), # 附魔在 SETASIDE
action=Seq(
ReturnControl(Ctx.HOST),
RemoveSelf(),
),
),),
)

附魔自己订阅”回合结束”,自己归还控制权,自己删除自己。宿主完全不需要知道自己被暗影狂乱了。 这是良好封装。

13.2 沉默:一个精确定义的操作

沉默的规则文本是”移除所有当前的卡牌文本、附魔和技能”。精确定义:

SILENCEABLE_TAGS = {
Tag.TAUNT, Tag.DIVINE_SHIELD, Tag.CHARGE, Tag.RUSH, Tag.STEALTH,
Tag.POISONOUS, Tag.LIFESTEAL, Tag.WINDFURY, Tag.MEGA_WINDFURY,
Tag.SPELLPOWER, Tag.REBORN, Tag.ELUSIVE,
}

def silence(game, e: Entity):
if e[Tag.CARDTYPE] != CardType.MINION:
return
# 1. 移除所有附魔
for ench in list(e._enchantments):
remove_enchantment(game, ench)
# 2. 注销所有触发器(亡语、光环、"每当…")
game.event_bus.unregister_all(e)
# 3. 清除关键词标签
for t in SILENCEABLE_TAGS:
e.tags.pop(t, None)
# 4. 标记
e[Tag.SILENCED] = 1
# 5. 光环重算
game.aura_bus.mark_dirty()

沉默不会移除的东西(这是考点):

状态沉默后原因
已受伤害(DAMAGE保留伤害不是文本
冻结(FROZEN保留冻结是状态不是文本
召唤失调(EXHAUSTED保留同上(但沉默会移除冲锋,导致依然不能攻击)
控制权保留《心灵控制》不可被沉默撤销
生命值被改(基础 HEALTH回到基础值会导致上限降低 → 可能直接死
来自光环的 buff保留光环来自别的实体,重算后依然生效

最后一条是最容易错的:沉默一个受《暴风城勇士》光环加成的随从,它依然是 +1/+1 的。因为光环不是宿主的附魔,是勇士持续提供的。

13.3 持续时间与到期

附魔有几种生命周期:

class Duration(IntEnum):
PERMANENT = 0 # 《王者祝福》
END_OF_TURN = 1 # 《狂野怒火》
START_OF_NEXT = 2
WHILE_IN_HAND = 3 # 手牌折扣(离手即失效?不,通常保留)
N_TURNS = 4
UNTIL_SOURCE_LEAVES = 5 # 这个其实应该用光环

END_OF_TURN 的实现有个坑:是”你的回合结束”还是”任意回合结束”?

炉石的规则是”本回合结束“,也就是当前回合的结束,无论是谁的回合。《暗影狂乱》在你的回合打出,在你的回合结束时归还。

def cleanup_expired_enchantments(game):
for e in list(game.all_entities()):
for ench in list(e._enchantments):
if ench.duration == Duration.END_OF_TURN:
remove_enchantment(game, ench)

end_turn() 的第 10 步执行。

13.4 附魔与复制的交互

一个历史 bug 温床:复制一个随从时,复制的是基础卡还是当前状态?

炉石的规则(现代):

行为
《克隆》(复制一个随从,放入手牌)复制基础卡(无附魔)
《镜像实体》复制基础卡
《恐怖的奴隶主》(召唤一个复制)复制当前状态(含附魔)
《叛逃者》(获得一个复制)视卡而定
《恩佐斯,深海领主》复制死亡时的状态(含附魔!)

所以引擎需要两个不同的复制操作:

class CopyMode(IntEnum):
BASE = 0 # 只复制 CARD_ID,重新从定义生成
FULL = 1 # 复制当前所有 tag 与附魔
SNAPSHOT = 2 # 复制死亡快照

def copy_entity(game, src: Entity, mode: CopyMode, controller: int) -> Entity:
if mode == CopyMode.BASE:
return game.create_entity(src[Tag.CARD_ID], controller)
new = game.create_entity(src[Tag.CARD_ID], controller)
if mode == CopyMode.FULL:
for tag, v in src.tags.items():
if tag in (Tag.ENTITY_ID, Tag.ZONE, Tag.ZONE_POSITION,
Tag.CONTROLLER, Tag.OWNER):
continue
new.tags[tag] = v
for ench in src._enchantments:
apply_enchantment(game, new, ench[Tag.CARD_ID])
return new

为什么要区分? 因为如果全都是 FULL 复制,一张 3 费的《克隆》就能复制一个 20/20 的巨型随从,游戏就崩了。BASE 复制是平衡性要求。


第 14 章 把它们拼起来:一张卡的完整生命周期

用《砰砰博士》走一遍全流程,检验前面所有机制。

BOOM_BOT = CardDef(
card_id="GVG_110t", name="砰砰机器人",
cost=1, card_type="MINION", attack=1, health=1, race="MECHANICAL",
deathrattle=Damage(Sel.ENEMY_CHARACTERS.random(1),
RandomRange(1, 4)), # 1-4 点随机伤害
)

DR_BOOM = CardDef(
card_id="GVG_110", name="砰砰博士",
cost=7, card_type="MINION", attack=7, health=7,
battlecry=Repeat(2, Summon("GVG_110t")),
)

打出砰砰博士

1. play_card(砰砰博士)
├─ compute_cost → 7,扣费
├─ move(手牌 → SETASIDE)
├─ emit(PLAY_CARD)
├─ move(SETASIDE → PLAY, position=3)
│ └─ 战场其余随从 ZONE_POSITION 重排
├─ EXHAUSTED = 1(无冲锋)
├─ event_bus.register_card(砰砰博士) → 无触发器
├─ aura_bus.mark_dirty()
├─ emit(SUMMON, 砰砰博士)
│ → 对手《镜像实体》可在此响应(但它订阅的是 MINION_PLAYED)
├─ emit(MINION_PLAYED, 砰砰博士)
│ → 对手《镜像实体》入队
├─ run(battlecry = Repeat(2, Summon(GVG_110t)))
│ ├─ Summon #1
│ │ ├─ create_entity(SETASIDE) → ENTITY_ID = 42
│ │ ├─ emit(PRE_SUMMON)
│ │ ├─ move(→ PLAY, position=4) ← 砰砰博士右边
│ │ ├─ register_card → 注册亡语
│ │ └─ emit(SUMMON) → 《飞刀杂耍者》类在此入队
│ └─ Summon #2 → ENTITY_ID = 43, position=5
├─ COMBO_ACTIVE = 1
├─ emit(AFTER_PLAY)
├─ resolve_queue()
│ ├─ 《镜像实体》:复制砰砰博士(基础卡)到对手场上
│ ├─ 《飞刀杂耍者》×2(如果有)
│ └─ ...
└─ process_deaths()

砰砰机器人被打死

2. damage(砰砰机器人#42, 3, source=火球术)
├─ 无免疫、无圣盾
├─ DAMAGE = 3,current_health = 1 - 3 = -2
└─ emit(DAMAGE, fatal=True)

3. process_deaths()
├─ 收集:[#42]
├─ mark_death(#42):快照 position=4, adjacent=(砰砰博士, #43)
├─ move(#42 → GRAVEYARD)
├─ aura_bus.mark_dirty()
├─ emit(DEATH, #42) → 亡语入队
└─ resolve_queue()
└─ Damage(Sel.ENEMY_CHARACTERS.random(1), RandomRange(1,4))
├─ 敌方角色 = [敌方英雄, 敌方随从...]
├─ game.rng.choice(...) → 选中敌方英雄
├─ game.rng.randint(1,4) → 3
└─ damage(敌方英雄, 3, source=#42)
└─ 敌方英雄先扣护甲
4. process_deaths() 第二轮 → 无新死亡,退出

注意 RNG 消费顺序:先选目标,再摇伤害。这个顺序必须固定,否则回放会不一致。真实炉石也是这样——这就是为什么”炉石的随机数是提前生成的”这个说法是错的,实际是按固定顺序消费一个确定序列


第四部分 关键词编年史:2014 → 2026

每个关键词都按同一个模板剖析: 卡面文本 → 设计意图 → 引擎归类 → 实现代码 → 交互陷阱


第 15 章 基础与经典:奠定范式的十六个关键词(2014)

2014 年 3 月正式上线的基础版 + 经典版,一次性定义了整个引擎的六大范式。后面十二年的所有关键词,都是这六种范式的变体。

15.1 嘲讽(Taunt)

文本:敌方必须先攻击拥有嘲讽的随从。

归类:静态标签,在目标合法性检查时消费。

# 实现:不是一个"效果",而是 is_legal_attack_target 里的一个查询
def taunt_wall(game, defender_side) -> list[Entity]:
return [m for m in defender_side
if m.has(Tag.TAUNT)
and not m.has(Tag.STEALTH) # 潜行的嘲讽不生效
and not m.has(Tag.IMMUNE) # 免疫的嘲讽不生效
and not m.has(Tag.DORMANT)] # 休眠的嘲讽不生效

设计意图:给防守方一个”墙”,让快攻不能无脑打脸。这是炉石唯一的防御性机制(没有 MTG 的阻挡)。

陷阱

  • 嘲讽不影响法术和英雄技能,只影响攻击
  • 三个”不生效”条件是分别在不同版本补上的(潜行嘲讽是 2014 年就有的,免疫嘲讽是《冰箱法》时代补的,休眠嘲讽是 2019 年《暗影崛起》补的)
  • 嘲讽不影响”随机攻击”(《克苏恩之刃》类),因为那不是攻击宣言

15.2 冲锋(Charge)

文本:可以在使用的当回合攻击。

归类:静态标签,在 can_attack 检查时消费。

def can_attack(game, e) -> bool:
if e[Tag.FROZEN]: return False
if e.has(Tag.CANNOT_ATTACK): return False
if e[Tag.ATK] <= 0: return False
max_attacks = 4 if e.has(Tag.MEGA_WINDFURY) else (2 if e.has(Tag.WINDFURY) else 1)
if e[Tag.NUM_ATTACKS] >= max_attacks: return False
if e[Tag.EXHAUSTED] and not e.has(Tag.CHARGE) and not e.has(Tag.RUSH):
return False
return True

历史:2019 年冲锋被大规模削弱——所有 OTK 组合卡都被改成《突袭》。这是一次关键词层面的平衡性手术,只有数据驱动的引擎才能做到(改一个 tag,不改代码)。

15.3 圣盾(Divine Shield)

归类:静态标签,在伤害管线中消费(一次性)。

关键实现细节在第 8 章已经给过,重点是三条:

  1. 圣盾PRE_DAMAGE 之后消耗,所以”如果这次伤害是 0,圣盾不破”
  2. 圣盾破了之后 damage() 返回 0,所以剧毒/吸血/超杀都不生效
  3. 圣盾只挡伤害,不挡消灭(《暗影词术:死亡》照样杀)
# 反例:错误实现
if target.has(Tag.DIVINE_SHIELD):
target[Tag.DIVINE_SHIELD] = 0
return amount # ❌ 返回了伤害值,吸血会生效

15.4 风怒(Windfury)/ 超级风怒(Mega-Windfury)

已在 can_attack 里。超级风怒是 GvG(2014.12)引入的,只有一张《雷神》。

陷阱:风怒 + 冲锋 的随从,第一回合能攻击两次。这是 OTK 的经典组件,也是《任务贼》被削的原因。

15.5 潜行(Stealth)

归类:静态标签,在攻击目标合法性法术目标合法性双重检查。

# 潜行的解除条件:攻击后
def attack(...):
if attacker.has(Tag.STEALTH):
attacker[Tag.STEALTH] = 0 # 攻击宣言后立即失去,不是攻击结算后

陷阱:潜行随从被 AOE 打到(AOE 不需要指向),被随机效果选中。

15.6 剧毒(Poisonous)

正式关键词化是 2016 年 12 月《加基森》,但机制早在 2014 年《毒蜘蛛》就存在(当时是纯文本)。

归类:伤害后处理。

# 严格定义:造成"伤害"后消灭。三个条件缺一不可
def apply_poison(game, source, target, dealt: int):
if not source.has(Tag.POISONOUS): return
if dealt <= 0: return # 圣盾挡住 → 不触发
if target[Tag.CARDTYPE] != CardType.MINION: return # 对英雄无效
target[Tag.TO_BE_DESTROYED] = 1

陷阱:剧毒是标记消灭,不是立即消灭。所以”1/1 剧毒 vs 10/10″是同归于尽,因为死亡结算阶段两者一起判定。

15.7 法术伤害 +X(Spell Damage)

归类:光环式全局修饰。

def spellpower(game, pid) -> int:
total = 0
for e in game.zones.get(pid, Zone.PLAY):
total += e[Tag.SPELLPOWER]
return total

陷阱(大量):

情况是否加成
法术造成伤害
法术治疗✗(除非有《奥金尼》转伤害,则 ✓)
英雄技能伤害
战吼伤害
亡语伤害
武器伤害
法术召唤的随从的伤害
法术造成的多段伤害每段都加(《奥术飞弹》每发 +1)
法术造成的伤害被《暗言术:灭》消灭无关

最后一条:《奥术飞弹》三发,法术伤害+1 会让它变成 3 发 2 点 = 6 点,不是 3+1=4 点。这个”每段都加”的规则,在实现上意味着加成必须在最内层的 damage() 调用点应用,而不是在 Action 层。

class Damage(Action):
def run(self, game, ctx):
amt = resolve_amount(self.amount, game, ctx)
# ★ 是否是法术上下文,由 ctx.source 的类型 + 触发路径决定
if game.is_spell_damage_context(ctx):
amt += game.spellpower(ctx.controller)
amt = game.apply_damage_multipliers(amt, ctx) # 《野兽之王》类翻倍
for t in self.sel.eval(game, ctx):
game.damage(t, amt, source=ctx.source)

is_spell_damage_context 的判定(推断):

def is_spell_damage_context(self, ctx) -> bool:
src = ctx.source
if src[Tag.CARDTYPE] == CardType.SPELL:
return True
# 英雄技能:只有少数几个吃加成(历史上《火焰冲击》曾经吃过,后来改了)
if src[Tag.CARDTYPE] == CardType.HERO_POWER:
return src.defn.spell_damage_affected
# 附魔携带的触发(如《狂野炎术师》)不吃
return False

15.8 过载(Overload)

归类:资源系统的延迟惩罚。

# 两个 tag 的双缓冲设计
class Overload(Action):
def __init__(self, n): self.n = n
def run(self, game, ctx):
p = game.player(ctx.controller)
p[Tag.OVERLOAD_OWED] += resolve_amount(self.n, game, ctx)

# begin_turn 时结算
p[Tag.OVERLOAD_LOCKED] = p[Tag.OVERLOAD_OWED]
p[Tag.OVERLOAD_OWED] = 0
p[Tag.MANA_AVAILABLE] = p[Tag.MANA_CRYSTALS] - p[Tag.OVERLOAD_LOCKED]

为什么要两个 tag? 因为在你的回合里可以同时存在”本回合被锁的”和”下回合要锁的”。《雷霆崖勇士》能在同一回合看到这两个值。

陷阱:过载在打出牌时结算,即使牌被反制。《法术反制》反掉《闪电箭》,你依然过载 1。

15.9 连击(Combo)

归类:出牌流程的条件分支。

p[Tag.COMBO_ACTIVE]   # 本回合是否已出过牌
# begin_turn: p[Tag.COMBO_ACTIVE] = 0
# play_card 末尾: p[Tag.COMBO_ACTIVE] = 1

陷阱

  • 连击判定的是”打出过牌“,不是”打出过其他牌”。所以第二张连击卡能触发
  • 硬币算牌,所以先手劣势在这里被补偿
  • 英雄技能不算
  • 被《法术反制》反掉的牌(因为已经”打出”)

15.10 战吼(Battlecry)与亡语(Deathrattle)

这两个是炉石的灵魂机制,前面已详细讨论。补充几点:

战吼的触发条件(严格):

  • 必须是从手牌打出play_card 路径)
  • 《召唤》《复活》《变形》不触发
  • 《恐怖的奴隶主》类”召唤一个复制”不触发
  • 《铜镜》《混乱术士》类”重复战吼”是特殊 Action

亡语的触发条件

  • 必须是从战场死亡
  • 被《变形》不触发(实体没死)
  • 被《移出游戏》不触发
  • 手牌里被弃掉不触发(除非是《手牌亡语》的少数特殊卡)
  • 被沉默后不触发(触发器已注销)

15.11 奥秘(Secret)

归类:预注册触发器 + 特殊区域。

def play_secret(game, card):
pid = card[Tag.CONTROLLER]
# 同名奥秘不能重复
existing = {s[Tag.CARD_ID] for s in game.zones.get(pid, Zone.SECRET)}
if card[Tag.CARD_ID] in existing:
return False
if len(game.zones.get(pid, Zone.SECRET)) >= 5:
return False
game.zones.move(card, Zone.SECRET)
game.event_bus.register_card(card)
return True


class RevealSecret(Action):
def run(self, game, ctx):
s = ctx.source
game.emit_immediate(Event.SECRET_REVEALED, entity=s)
game.event_bus.unregister_all(s)
game.zones.move(s, Zone.GRAVEYARD)

陷阱

  • 奥秘不能在自己回合触发(大部分有 condition=Cond.enemy_turn()
  • 一次事件可能触发多个奥秘,按 ENTITY_ID 顺序
  • 奥秘触发时先揭示再执行,所以《爆炸陷阱》的伤害发生在对手看到它之后
  • 奥秘触发后即使效果失败(如场满),照样消耗

15.12 抉择(Choose One)

归类:出牌时的分支选择。

# 实现方式:两张隐藏的子卡
WRATH = CardDef(
card_id="EX1_154", name="愤怒", cost=2, card_type="SPELL",
choose_one=("EX1_154a", "EX1_154b"),
targeting=Sel.ALL_MINIONS,
)
WRATH_A = CardDef(card_id="EX1_154a", spell=Damage(Ctx.TARGET, 3))
WRATH_B = CardDef(card_id="EX1_154b", spell=Seq(Damage(Ctx.TARGET, 1), Draw(1)))

为什么用子卡而不是 Choose Action? 因为:

  1. 客户端要显示两张独立的卡面(有各自的插图和文本)
  2. 《弗洛普的槌子》类”两种效果都触发”需要独立执行两张子卡
  3. 目标筛选可能不同(一个要目标,一个不要)
  4. 费用可能不同(历史上《月火术》类)
def resolve_choose_one(game, card, choice: int, target):
if game.player(card[Tag.CONTROLLER]).has_choose_both_aura():
for sub_id in card.defn.choose_one:
game.run(CARD_DB[sub_id].spell, source=card, target=target)
else:
sub = CARD_DB[card.defn.choose_one[choice]]
game.run(sub.spell, source=card, target=target)

2025 年《翡翠梦境》把抉择扩展到了所有职业,并引入了”抉择多选(Choose Multiple)”,允许选多个选项——上面的实现天然支持(把 choice: int 换成 choices: list[int])。

15.13 冻结(Freeze)、沉默(Silence)、免疫(Immune)

  • 冻结Tag.FROZEN = 1,在 begin_turn 时清除,但只有”该角色本回合没有攻击过”才清除(避免风怒卡冻结失效)。
def thaw(game, pid):
for e in game.characters(pid):
if e[Tag.FROZEN] and e[Tag.NUM_ATTACKS] == 0:
e[Tag.FROZEN] = 0

实际规则更微妙:如果角色在被冻结的回合本来就无法攻击(如召唤失调),冻结会持续到下下回合。这是”冻结在你的回合开始时解除,但前提是你上个回合没能攻击”的实现结果。

  • 沉默:见 13.2
  • 免疫Tag.IMMUNE,在 damage() 最前面拦截,同时也在目标合法性里拦截

15.14 激怒(Enrage,已废弃)

2014-2018 存在,后来被”受到伤害后“(Frenzy 的前身)取代。它是个状态光环

# 《砂纸喷剂》——用《暴怒的狼人》:激怒:+3 攻击力
AMANI_BERSERKER = CardDef(
card_id="EX1_393", attack=2, health=3,
auras=(AuraDef(
selector=Self_(), tag=Tag.ATK, amount=3, include_self=True,
condition=Cond.self_damaged(), # DAMAGE > 0
),),
)

为什么废弃? 因为它是光环,而光环需要持续重算,且它与治疗的交互反直觉(治疗会取消激怒)。2019 年官方把所有激怒卡改成了”受到伤害后永久 +X”,也就是从光环范式改成触发范式。这是一次教科书级的”用简单范式替换复杂范式”的重构。


第 16 章 2014–2016:探索期

16.1 纳克萨玛斯的诅咒(2014.07)——亡语主题

无新关键词,但它是第一次证明”一个已有关键词可以撑起一个资料片“。工程上的意义:亡语系统被压力测试,暴露了大量时序 bug(同时死亡的顺序、亡语召唤的位置、亡语触发亡语的递归)。第 7 章讲的死亡结算阶段,基本就是这个资料片之后成熟的。

16.2 地精大战侏儒(2014.12)——随机性与零件

新增:机械(Mech)种族、超级风怒、零件(Spare Part)

零件不是关键词,但它引入了一个重要机制:衍生卡池

SPARE_PARTS = ["PART_001", "PART_002", ..., "PART_007"]

class AddSparePart(Action):
def run(self, game, ctx):
cid = game.rng.choice(SPARE_PARTS)
game.add_to_hand(ctx.controller, cid)

工程上,这要求引擎能在运行时创建卡牌定义里不存在的实体,并且这些实体不属于任何玩家的牌库。SETASIDE 区和”衍生物”概念在此固化。

GvG 还是炉石”随机性”争议的开端(《砰砰博士》《混乱奥술师》)。从工程角度,这些卡把 RNG 的确定性问题推到了台前——第 28 章会讲。

16.3 黑石山(2015.04)——龙

无新关键词。引入”手牌条件“这个查询模式:

Cond.holding_dragon = lambda: ExistsIn(
InZone(Zone.HAND), IsRace("DRAGON")
)

BLACKWING_CORRUPTOR = CardDef(
card_id="BRM_034", cost=5, attack=5, health=4,
battlecry=If(Cond.holding_dragon(),
Damage(Ctx.TARGET, 3)),
targeting=Sel.ALL_CHARACTERS,
)

工程意义:条件判定要能查询非公开区域(手牌)。这意味着服务器上的 Condition 求值必须能访问完整状态,而客户端只能看到部分——AI 和客户端预测在这里第一次分岔

16.4 冠军的试炼(2015.08)——励志、对决

励志(Inspire):每当你使用英雄技能后触发。

class Keyword_Inspire:
"""归类:触发型,订阅 HERO_POWER_USED"""
@staticmethod
def make(action):
return TriggerDef(
on=Event.HERO_POWER_USED,
condition=Cond.controller_is_owner(),
action=action,
)

# 《大厅守卫》:励志:获得嘲讽
LOWLY_SQUIRE = CardDef(
card_id="AT_082", cost=1, attack=1, health=2,
triggers=(Keyword_Inspire.make(Buff(Self_(), "AT_082e")),),
)

对决(Joust):双方各从牌库随机展示一张随从,如果你的费用更高,获得奖励。

class Joust(Action):
def __init__(self, on_win): self.on_win = on_win
def run(self, game, ctx):
me, opp = ctx.controller, 1 - ctx.controller
my_pool = [c for c in game.zones.get(me, Zone.DECK) if c.is_minion]
opp_pool = [c for c in game.zones.get(opp, Zone.DECK) if c.is_minion]
mine = game.rng.choice(my_pool) if my_pool else None
theirs = game.rng.choice(opp_pool) if opp_pool else None
game.reveal(mine, theirs) # 客户端动画
my_cost = game.compute_cost(mine) if mine else -1
op_cost = game.compute_cost(theirs) if theirs else -1
if my_cost > op_cost:
self.on_win.run(game, ctx)

对决失败了(作为机制)。原因是它的方差太大且不可控,玩家讨厌”抽卡决定胜负”。工程上它也有个问题:它泄露信息(展示了对手牌库的一张牌),但泄露是随机的,AI 无法利用。这是”设计上有趣但实际不好玩”的典型。

16.5 探险者协会(2015.11)——发现(Discover)★

发现是炉石历史上最成功的关键词,没有之一。它至今仍是常青机制。

文本:从三张随机的(职业相关的)卡牌中选择一张。

归类中断型——需要玩家输入,打破了”结算原子性”(见 5.3)。

class Discover(Action):
def __init__(self, pool: "CardPool", count=3, then=None):
self.pool, self.count, self.then = pool, count, then

async def run(self, game, ctx):
candidates = self.pool.build(game, ctx)
if not candidates:
return
# ★ 无放回抽样,保证三张不重复
picks = game.rng.sample(candidates, min(self.count, len(candidates)))
chosen = await game.ask_choice(ctx.controller, picks, kind="DISCOVER")
act = self.then or AddToHand(chosen)
await act.run(game, replace(ctx, stored={"discovered": chosen}))

卡池(CardPool)的定义是发现的精髓:

@dataclass
class CardPool:
card_type: str | None = None
klass: str | None = "PLAYER_CLASS" # 特殊值:使用者的职业
race: str | None = None
cost_range: tuple[int,int] | None = None
from_zone: Zone | None = None # 从牌库/墓地发现
collectible_only: bool = True # 排除衍生物
sets: frozenset[str] | None = None # 限定版本(如"经典卡")

def build(self, game, ctx) -> list[str]:
pool = game.card_index.query(
card_type=self.card_type,
klass=(game.player(ctx.controller).klass
if self.klass == "PLAYER_CLASS" else self.klass),
race=self.race, cost_range=self.cost_range,
collectible=self.collectible_only, sets=self.sets,
)
return pool

为什么发现如此成功?三个工程/设计层面的理由

  1. 它把随机性变成了决策。RNG 生成三个选项,但玩家做选择。这消除了”被随机数杀死”的挫败感,同时保留了多样性。
  2. 它是版本无关的。卡池是动态查询的,新资料片自动加入,老卡自动轮换出去。一张 2015 年的发现卡在 2026 年依然工作,且效果完全不同。这是数据驱动的最高价值体现。
  3. 它的实现是可复用的。后来的《适应》《打捞》《挖掘》《锻造》《黑暗恩赐》全都是 Discover 的变体,共用同一套 UI 和挂起机制。

发现的变体谱系

Discover (2015)
├── Adapt (2017) ← 从固定的 10 个 buff 里选 3 个
├── Discover from deck ← 卡池 = 你的牌库
├── Dredge (2022) ← 卡池 = 牌库底三张,结果放牌库顶
├── Excavate (2023) ← 卡池按"挖掘等级"分层
├── Forge (2023) ← 不是 Discover,但共用 UI
└── Dark Gift (2025) ← 发现一个 buff 附加给随从

16.6 上古之神的低语(2016.04)——克苏恩与腐化

无正式关键词,但引入了两个机制模式:

(1)克苏恩计数器:所有”强化克苏恩”的卡都给一个跨区域的持久附魔

class BuffCthun(Action):
def __init__(self, atk, hp): self.atk, self.hp = atk, hp
def run(self, game, ctx):
# 找到克苏恩,无论它在哪个区域(手牌/牌库/战场/墓地)
for e in game.all_entities_of(ctx.controller, card_id="OG_280"):
apply_enchantment(game, e, "OG_280e", atk=self.atk, hp=self.hp)
# 如果克苏恩不在(被抽走/被消灭),也要记录,
# 因为后来可能通过《克苏恩教徒》再生成一个
game.player(ctx.controller).cthun_buff += (self.atk, self.hp)

工程难点:附魔必须能作用于牌库里的卡。这要求 Zone.DECK 里的实体是完整实体(有 tag 表),不是简单的 card_id 列表。很多人做卡牌游戏时会把牌库做成 list[str],这是个陷阱——牌库里的卡必须是实体

(2)腐化(Corrupted):一张卡有”正常版”和”腐化版”两个定义,构筑时二选一。这只是卡池层面的事,引擎无感知。

16.7 加基森(2016.12)——剧毒关键词化、手牌 buff、三职业

剧毒从纯文本变成关键词。这次”关键词化”本身是个有意思的工程动作:

  • 之前:《毒蜘蛛》的文本是”每当该随从对一个随从造成伤害,消灭该随从”,实现是一个 TriggerDef(on=DAMAGE, source_only=True, action=Destroy(...))
  • 之后:keywords={"POISONOUS"},实现移到伤害管线里

为什么要改? 因为触发式实现有个 bug:它是事件驱动的,所以在”同时死亡”的场景下顺序会出错。移到伤害管线里之后,剧毒的标记和伤害是同步的。

手牌 buff(《克罗地亚》类”使你手牌中的随从获得 +1/+1″)要求:手牌里的实体也能被附魔,且附魔在打出后保留。这跟克苏恩的需求是一样的。


第 17 章 2017:机制爆发的一年

17.1 安戈洛(2017.04)——适应、任务、元素

适应(Adapt)

文本:从十种强化中发现三种。

十种强化是固定的:

ADAPTATIONS = [
("UNG_999t2", "坚硬如铁", GiveKeyword(Ctx.TARGET, "DIVINE_SHIELD")),
("UNG_999t3", "燃烧之核", Buff(Ctx.TARGET, atk=3, hp=0)),
("UNG_999t4", "利刃尖刺", GiveKeyword(Ctx.TARGET, "POISONOUS")),
("UNG_999t5", "生命之流", Buff(Ctx.TARGET, atk=0, hp=3)),
("UNG_999t6", "疾如猎豹", GiveKeyword(Ctx.TARGET, "WINDFURY")),
("UNG_999t7", "巨大之力", Buff(Ctx.TARGET, atk=1, hp=1)), # 实际是 +1/+1
("UNG_999t8", "钢铁之皮", GiveKeyword(Ctx.TARGET, "TAUNT")),
("UNG_999t10", "阴影伪装", GiveKeyword(Ctx.TARGET, "STEALTH")),
("UNG_999t13", "毒性之刺", Deathrattle(Summon("UNG_999t2t1", n=2))),
("UNG_999t14", "疯狂增殖", ...),
]

class Adapt(Action):
def __init__(self, sel): self.sel = sel
async def run(self, game, ctx):
picks = game.rng.sample(ADAPTATIONS, 3)
chosen = await game.ask_choice(ctx.controller, picks, kind="ADAPT")
for t in self.sel.eval(game, ctx):
await chosen.action.run(game, replace(ctx, target=t))

设计洞察:适应把 Discover 的”无限卡池”收缩到”固定十选三”。这让它可预测(对手知道你可能获得什么),同时保留了选择。工程上它复用了 Discover 的全部基础设施,新增代码不到 30 行。

任务(Quest)★

文本:任务:完成 X 次 Y。奖励:Z。

任务是炉石第一个有状态的、跨回合的、可见的计数器机制

@dataclass(frozen=True)
class QuestDef:
trigger_event: Event
condition: "Condition"
required: int
reward_card_id: str

QUEST_OPEN_THE_WAYGATE = CardDef(
card_id="UNG_028", name="开启传送门", cost=1, card_type="SPELL",
keywords=frozenset({"QUEST"}),
quest=QuestDef(
trigger_event=Event.SPELL_CAST,
condition=Cond.spell_not_in_starting_deck(), # "使用一张不在你初始牌库中的法术"
required=6,
reward_card_id="UNG_028t", # 时光扭曲
),
)

引擎侧的实现:

def play_quest(game, card):
pid = card[Tag.CONTROLLER]
game.zones.move(card, Zone.SECRET) # ★ 任务放在奥秘区!
card[Tag.QUEST_PROGRESS] = 0
card[Tag.QUEST_TOTAL] = card.defn.quest.required
game.event_bus.register(card, TriggerDef(
on=card.defn.quest.trigger_event,
zones=frozenset({Zone.SECRET}),
condition=card.defn.quest.condition & Cond.controller_is_owner(),
action=QuestProgress(),
))

class QuestProgress(Action):
def run(self, game, ctx):
q = ctx.source
q[Tag.QUEST_PROGRESS] += 1
if q[Tag.QUEST_PROGRESS] >= q[Tag.QUEST_TOTAL]:
game.zones.move(q, Zone.GRAVEYARD)
game.event_bus.unregister_all(q)
game.add_to_hand(ctx.controller, q.defn.quest.reward_card_id)

为什么任务放在奥秘区? 这是一个纯粹的复用决策:奥秘区已经有了”可见的、非战场的、能承载触发器的实体容器”的全部基础设施。代价是《奥秘管理员》能抓到任务(曾经是 bug,后来加了 Cond.is_secret_not_quest() 过滤)。

任务的一个隐蔽陷阱QUEST_PROGRESS 是一个 tag,会被网络同步给双方。所以对手能看到你的任务进度——这是设计要求(任务是公开信息),但也意味着引擎不能把它藏起来。

元素(Elemental)

新种族,但带来了一个新的查询模式:“如果你在上个回合使用过元素”

# 需要一个玩家级的历史状态
class PlayerState:
played_elemental_last_turn: bool
played_elemental_this_turn: bool

# end_turn 时轮转
def rotate_elemental_flags(p):
p.played_elemental_last_turn = p.played_elemental_this_turn
p.played_elemental_this_turn = False

工程意义:这是引擎第一次需要维护”上个回合发生了什么“的历史状态。之前所有条件都是查询当前状态。这打开了一个新的设计空间,后来的《同源》(Kindred, 2025)就是它的直系后代。

17.2 冰封王座(2017.08)——吸血、英雄牌

吸血(Lifesteal)

归类:伤害后处理(与剧毒同类)。

# 在 damage() 内部
if source is not None and source.has(Tag.LIFESTEAL):
game.heal(game.player(source[Tag.CONTROLLER]).hero, actual_dealt)

陷阱清单

情况是否吸血
圣盾挡住✗(实际伤害 0)
目标是免疫
超杀(打出溢出伤害)只算实际造成的(打 3 血随从造成 5 点,只吸 3?)

最后一条的真实答案是:吸 5。因为 damage() 返回的是”扣了多少 DAMAGE 标记”,而不是”实际减少了多少生命”。5 点伤害打 3 血随从,DAMAGE += 5,返回 5。这是”伤害标记”建模的又一个后果。

情况是否吸血
AOE 打多个目标每个都吸,累加
己方随从受到的伤害✓(如果来源有吸血)
自己打自己
你的英雄满血✓ 但溢出部分浪费(除非有《过量治疗》)

英雄牌(Hero Card)

归类:实体替换。

class ReplaceHero(Action):
def __init__(self, hero_card_id, armor): ...
def run(self, game, ctx):
p = game.player(ctx.controller)
old = p.hero
new = game.create_entity(self.hero_card_id, ctx.controller, zone=Zone.PLAY)
# ★ 继承的东西
new[Tag.DAMAGE] = old[Tag.DAMAGE] # 已受伤害保留
new[Tag.ARMOR] = old[Tag.ARMOR] + self.armor
new[Tag.NUM_ATTACKS] = old[Tag.NUM_ATTACKS]
new[Tag.FROZEN] = old[Tag.FROZEN]
# ★ 不继承的:附魔(清除)、武器(保留!武器是独立实体)
for ench in list(old._enchantments):
remove_enchantment(game, ench)
p.hero = new
# 替换英雄技能
p.hero_power = game.create_entity(self.hero_power_id, ctx.controller)
game.event_bus.unregister_all(old)
game.event_bus.register_card(new)

为什么英雄牌是工程上的大事? 因为它证明了”英雄也是实体“这个设计的价值。如果英雄是硬编码的特殊对象,英雄牌就得改引擎;因为英雄只是一个 CARDTYPE=HERO 的实体,替换它就是普通的实体操作。

这也解释了为什么英雄能被 buff(《紫罗兰传授者》给英雄 +攻)、能有嘲讽(少数卡)、能有圣盾(《不朽》)。

17.3 狗头人与地下城(2017.12)——召募、法术石

召募(Recruit)

文本:从你的牌库中随机召唤一个随从。

class Recruit(Action):
def __init__(self, condition=None, count=1):
self.condition, self.count = condition, count
def run(self, game, ctx):
deck = game.zones.get(ctx.controller, Zone.DECK)
pool = [c for c in deck if c[Tag.CARDTYPE] == CardType.MINION
and (not self.condition or self.condition.eval(game, c, ctx))]
for _ in range(self.count):
if not pool: break
m = game.rng.choice(pool)
pool.remove(m)
game.zones.move(m, Zone.PLAY) # ★ 直接从牌库到战场
game.event_bus.register_card(m)
game.emit(Event.SUMMON, entity=m)

注意:召募不触发战吼,因为不走 play_card 路径。这是”战吼只在从手牌打出时触发”这条规则的直接后果,也是玩家最常问的问题之一。

召募是一个失败的关键词——它只用了一个资料片就被弃用了。原因:它在设计上鼓励”抽空牌库”这种反直觉的构筑(牌库里只留大随从),而且方差太大。

法术石(Spellstone)

归类:手牌内升级。

LESSER_JASPER_SPELLSTONE = CardDef(
card_id="LOOT_051", cost=1,
upgrade_condition=Cond.gained_armor(),
upgrade_required=3,
upgrade_to="LOOT_051t1",
triggers=(TriggerDef(
on=Event.ARMOR_GAINED,
zones=frozenset({Zone.HAND}), # ★ 手牌中生效
condition=Cond.controller_is_owner(),
action=SpellstoneProgress(),
),),
)

class SpellstoneProgress(Action):
def run(self, game, ctx):
s = ctx.source
s[Tag.SPELLSTONE_PROGRESS] += 1
if s[Tag.SPELLSTONE_PROGRESS] >= s.defn.upgrade_required:
transform(s, s.defn.upgrade_to) # 手牌里变形!

工程意义:这是第一次在手牌里做变形。它要求:

  1. 手牌实体能注册触发器(zones={HAND}
  2. 变形能在非战场区域工作
  3. 客户端能显示”手牌里的卡变了”的动画

后来的《任务链》《锻造》《碎裂》全都建立在这套基础设施上。


第 18 章 2018:突袭改变一切

18.1 女巫森林(2018.04)——回响、突袭、单数/双数

突袭(Rush)★

文本:可以在使用的当回合攻击随从。

这是一个只用了一行代码改变了整个游戏平衡的关键词。

def is_legal_attack_target(game, attacker, defender):
...
if attacker.has(Tag.RUSH) and not attacker.has(Tag.CHARGE):
if attacker[Tag.NUM_TURNS_IN_PLAY] == 0 and defender.is_hero:
return False # ← 就这一行
...

为什么它如此重要? 因为它解决了炉石十年的核心平衡难题:冲锋既是”清场工具”又是”斩杀工具”。设计师想给玩家”立即清场”的能力,但每张冲锋卡都同时是 OTK 组件。突袭把这两个功能拆开了。

2019 年官方把《冲锋》从大量卡上换成了《突袭》——这是数据驱动架构最有价值的一次兑现。如果冲锋是硬编码的,这次手术要改几十处代码;因为它是 tag,只需要改卡牌数据。

回响(Echo)

文本:使用后,复制一张牌到你的手牌,直到本回合结束。

class Keyword_Echo:
@staticmethod
def on_played(game, card):
pid = card[Tag.CONTROLLER]
copy = game.create_entity(card[Tag.CARD_ID], pid, zone=Zone.SETASIDE)
copy.is_echo_copy = True # 回合结束时销毁
copy[Tag.TEMPORARY] = 1
# ★ 关键:复制品保留原卡的费用修正
for ench in card._enchantments:
if ench.affects_cost:
apply_enchantment(game, copy, ench[Tag.CARD_ID])
game.zones.move(copy, Zone.HAND)

陷阱:回响副本的费用继承折扣。所以《恶魔学者》+《回响》能无限打出。这被利用过,后来通过”回响卡不能被某些折扣影响”来平衡。

工程注意:回响副本必须在 end_turn 被清理,且清理时不触发弃牌事件(弃牌是 DISCARD,回响消失是 EXPIRE)。这个区分很重要,因为有卡是”每当你弃一张牌”触发的。

单数/双数(Even/Odd)

归类:开局时(Start of Game)+ 全局光环。

GENN_GREYMANE = CardDef(
card_id="GIL_692", cost=6, attack=6, health=5,
triggers=(TriggerDef(
on=Event.GAME_START,
zones=frozenset({Zone.DECK}), # ★ 在牌库里触发
condition=Cond.deck_has_no_odd_cost_cards(),
action=SetHeroPowerCost(1),
),),
)

工程意义Start of Game 触发器要求引擎在开局时扫描牌库内容并做条件判定。这需要牌库里的卡是完整实体(又一次)。

而且,这个触发器发生在换牌之前,所以它看到的是完整的 30 张牌。

18.2 砰砰计划(2018.08)——磁力

磁力(Magnetic)★

文本:可以将其融合到你的机械随从的左侧。

磁力是最复杂的关键词之一,因为它涉及”两个实体合并成一个”。

class Magnetize(Action):
"""把 source(磁力随从)融合到 target(机械)身上"""
def run(self, game, ctx):
src, tgt = ctx.source, ctx.target
# 1. 属性叠加(作为一个附魔)
ench = game.create_entity(src[Tag.CARD_ID] + "e", ctx.controller,
zone=Zone.SETASIDE)
ench.enchant_tags = {
Tag.ATK: src.defn.attack,
Tag.HEALTH: src.defn.health,
}
apply_enchantment_entity(game, tgt, ench)

# 2. 关键词转移
for kw in (Tag.TAUNT, Tag.DIVINE_SHIELD, Tag.RUSH, Tag.WINDFURY,
Tag.POISONOUS, Tag.LIFESTEAL):
if src.has(kw):
tgt[kw] = 1

# 3. 亡语与触发器转移(★ 最难的部分)
if src.defn.deathrattle:
game.event_bus.register(tgt, TriggerDef(
on=Event.DEATH, source_only=True,
action=src.defn.deathrattle,
owner_enchantment=ench, # 绑定到附魔,沉默时一起移除
))
for td in src.defn.triggers:
game.event_bus.register(tgt, replace(td, owner_enchantment=ench))

# 4. 记录融合历史(★ 沉默/死亡时要用)
tgt.magnetic_stack.append(src[Tag.CARD_ID])

# 5. 源实体消失(不进墓地,不触发亡语)
game.remove_entity(src)

磁力的四个陷阱

  1. 沉默融合体 → 所有磁力部件的效果一起消失,随从回到基础形态。这是因为所有磁力效果都挂在附魔上。
  2. 融合体死亡 → 所有磁力部件的亡语都触发(按融合顺序)。
  3. 磁力随从可以正常打出(不融合),此时它就是个普通随从。
  4. 不能融合到非机械上,也不能融合到”已经满了”的随从上(无此限制,可以无限叠)。

工程评价:磁力是”关键词复杂度”的一个上限案例。它需要 Action 能修改另一个实体的触发器注册表,这打破了”实体的行为由它的 CardDef 决定”这条不变式。真实炉石的实现(推断)确实是通过附魔携带触发器来做的,这也是为什么附魔必须是实体(13.1)。

其他

  • OmegaIf(Cond.mana_crystals_at_least(10), bonus)——纯条件判断,无新基础设施
  • Project:双方都受影响的对称效果——只是 Selector 的用法

18.3 拉斯塔哈的大乱斗(2018.12)——超杀

超杀(Overkill)

文本:在你的回合中造成超出击杀所需的伤害时触发。

class Keyword_Overkill:
@staticmethod
def make(action):
return TriggerDef(
on=Event.DAMAGE,
source_only=True, # 我造成的伤害
condition=Cond.my_turn() & Cond.overkill(),
action=action,
)

class OverkillCond(Condition):
def eval(self, game, e, ctx):
# ctx.amount 是造成的伤害,ctx.target_health_before 是目标伤害前的生命
return ctx.amount > ctx.target_health_before

陷阱

  • 只在你的回合触发。防守时反击造成超杀不触发。
  • 需要事件携带 target_health_before,所以 DAMAGE 事件的上下文必须记录”伤害前的生命值”。
# damage() 里
health_before = target.current_health
target[Tag.DAMAGE] += amount
self.emit(Event.DAMAGE, target=target, source=source, amount=amount,
target_health_before=health_before, # ★ 为超杀/荣誉击杀准备
fatal=target.current_health <= 0)

这个字段后来被三个关键词复用:超杀(amount > before)、荣誉击杀(amount == before)、狂乱(amount > 0 and after > 0)。一次多存一个字段,省了三次改动。 这是事件上下文设计的经验:宁可多带信息,不要让订阅者去反查。


第 19 章 2019:任务的回归与复生

19.1 暗影崛起(2019.04)——双生法术、跟班、阴谋

双生法术(Twinspell)

文本:使用后,将一张不带双生法术的复制放入你的手牌。

class Keyword_Twinspell:
@staticmethod
def on_cast(game, card):
twin_id = card.defn.twinspell_copy_id # 指向去掉关键词的版本
game.add_to_hand(card[Tag.CONTROLLER], twin_id)

与《回响》的区别:双生副本是永久的,不在回合结束消失,而且只复制一次。实现上更简单(不需要 TEMPORARY 标记)。

跟班(Lackey)

不是关键词,是一个衍生卡池

LACKEYS = ["DAL_613", "DAL_614", "DAL_615", "DAL_739", "DAL_740"]

class AddLackey(Action):
def run(self, game, ctx):
game.add_to_hand(ctx.controller, game.rng.choice(LACKEYS))

跟班的设计价值:它是低方差的随机。五个 1 费随从都是可接受的,所以”随机跟班”不会有天堂/地狱的落差。这是对 GvG 时代高方差随机的一次纠正。

阴谋(Scheme)

归类:手牌内成长(法术石的直系后代)。

class SchemeGrowth(Action):
"""每个回合结束时,阴谋的效果值 +1"""
def run(self, game, ctx):
ctx.source[Tag.SCHEME_LEVEL] += 1

DR_BOOMS_SCHEME = CardDef(
card_id="DAL_007", cost=5,
triggers=(TriggerDef(
on=Event.TURN_END, zones=frozenset({Zone.HAND}),
condition=Cond.controller_is_owner(),
action=SchemeGrowth(),
),),
spell=GainArmor(TagOf(Sel.SELF, Tag.SCHEME_LEVEL)),
)

工程价值:它把”手牌里的实体有可变状态”这件事常态化了。之后的任务链、旅客、星舰都依赖这个能力。

19.2 奥丹姆奇兵(2019.08)——复生

复生(Reborn)★

文本:该随从第一次死亡时,将以 1 点生命值重新召唤。

def summon_reborn(game, dead: Entity):
snap = dead.death_snapshot
new = game.create_entity(snap.card_id, snap.controller, zone=Zone.SETASIDE)
new[Tag.HEALTH] = 1 # ★ 生命值 = 1,不是基础值
new[Tag.ATK] = CARD_DB[snap.card_id].attack # 攻击力回到基础值
new[Tag.REBORN] = 0 # ★ 只能复生一次
game.zones.move(new, Zone.PLAY, snap.position)
game.event_bus.register_card(new)
game.emit(Event.SUMMON, entity=new)

陷阱清单

  1. 复生体是新实体(新 ENTITY_ID),所以”这个随从”类的引用会断
  2. 复生体不继承附魔(回到基础卡 + 1 血)
  3. 复生在亡语之后(第 7 章讲过)
  4. 复生体的 REBORN 被清除,不能二次复生
  5. 沉默会移除复生(REBORNSILENCEABLE_TAGS 里)
  6. 场满时复生失败(和普通召唤一样)
  7. 复生体触发”随从召唤时”效果

为什么复生的实现要放在死亡结算的第 6 步(亡语之后)? 我在 7.3 说过原因,这里补一个反例:如果复生在亡语之前,那么”复生 + 亡语造成 AOE”的随从会自己打死自己的复生体。实测炉石不会,所以顺序确定。

19.3 巨龙降临(2019.12)——探底、支线任务

探底 / 唤醒(Invoke)

文本:触发迦拉克隆的英雄技能。

class Invoke(Action):
def run(self, game, ctx):
p = game.player(ctx.controller)
p.invoke_count += 1
if p.galakrond_in_play_or_hand():
game.trigger_hero_power(ctx.controller, forced=True)
# 每 2 次探底升级迦拉克隆
if p.invoke_count in (2, 4):
game.upgrade_galakrond(ctx.controller)

工程模式:跨区域的计数器 + 阶段性升级。 这个模式在 2026 年的《先驱》(Herald)里被完整复用(每 2 次 Herald 升级一次巨型)。

支线任务(Sidequest)

小型任务,机制完全复用《任务》,只是数值更小、奖励更弱。零新增代码——这是关键词复用的最佳案例。



第 20 章 2020:新职业与法术学派前夜

20.1 外域的灰烬(2020.04)——流放、至尊、恶魔猎手

流放(Outcast)★

文本:如果这是你手牌中最左侧或最右侧的牌,触发额外效果。

这是炉石第一个真正依赖手牌位置的关键词。

class OutcastCond(Condition):
def eval(self, game, e, ctx):
hand = game.zones.get(e[Tag.CONTROLLER], Zone.HAND)
if not hand:
return False
return e is hand[0] or e is hand[-1]

# 用法:包在 If 里
SKULL_OF_GULDAN = CardDef(
card_id="BT_601", cost=5, card_type="SPELL",
spell=Seq(
Draw(3),
If(OutcastCond(), ReduceCostOfDrawn(3)),
),
)

工程难点ZONE_POSITION 在手牌里必须严格维护。之前手牌的位置只是显示顺序,现在它是规则的一部分。这意味着:

  1. 抽牌插入的位置必须确定(总是最右
  2. 生成的牌插入位置必须确定(总是最右
  3. 打出一张牌后,右侧所有牌左移
  4. 客户端拖动排序不能影响服务器状态(炉石不允许玩家排手牌,正是因为这个)

第 4 点很关键。如果玩家能自由排手牌,流放就没有意义了。所以炉石故意不提供手牌排序功能——这是一个由机制倒逼的 UX 决策。

(2026 年的《碎裂》把这个约束用到了极致:碎片被强制放在手牌两端。)

至尊(Prime)

文本:亡语:将一张”至尊版”洗入你的牌库。

纯组合:deathrattle = ShuffleIntoDeck("BT_XXXt")。零新基础设施。

恶魔猎手职业

工程意义:第十个职业。它验证了”职业是数据”这个设计——新增一个职业只需要:

  1. 一个 CLASS 枚举值
  2. 一个英雄实体定义
  3. 一个英雄技能定义
  4. 卡牌的 klass 字段

没有任何引擎代码改动。相比之下,2022 年的死亡骑士需要引擎改动(符文系统 + 尸块),因为它引入了新的资源类型。

20.2 通灵学园(2020.08)——法术迸发、双职业

法术迸发(Spellburst)★

文本:在你使用一张法术后,触发一次(仅一次)。

class Keyword_Spellburst:
@staticmethod
def make(action):
return TriggerDef(
on=Event.AFTER_SPELL,
condition=Cond.controller_is_owner(),
action=Seq(action, ConsumeSpellburst()),
once=True, # ★ 一次性
)

class ConsumeSpellburst(Action):
def run(self, game, ctx):
ctx.source[Tag.SPELLBURST_USED] = 1
game.event_bus.unregister_trigger(ctx.source, Event.AFTER_SPELL)

陷阱

  • 法术迸发是”使用法术后“,所以法术效果先结算,再触发迸发
  • 一旦触发就永久消耗,沉默不能重置(因为触发器已注销)
  • 但《变形》可以”重置”(因为变回来是新的定义)
  • 《铜镜》类复制的随从有自己的一次机会

once=True 的实现细节:注意上面同时用了 once=True 和显式的 unregister_trigger。为什么两个都要?因为 once 只保证”不会执行第二次”,但触发器还在订阅列表里,会消耗 CPU;显式注销是性能优化。真实引擎里这类”注销”很重要——一局游戏里会有数百个触发器注册/注销。

双职业卡

纯数据:klass 字段变成 frozenset({"MAGE", "ROGUE"})。构筑规则变了,引擎不变。

20.3 疯狂的达拉然(2020.11)——腐蚀

腐蚀(Corrupt)

文本:在你使用一张费用更高的牌后,该牌在你手中升级。

class Keyword_Corrupt:
@staticmethod
def make(upgraded_id):
return TriggerDef(
on=Event.PLAY_CARD,
zones=frozenset({Zone.HAND}),
condition=Cond.controller_is_owner() & Cond.played_cost_higher_than_self(),
action=TransformInHand(upgraded_id),
once=True,
)

class PlayedCostHigherThanSelf(Condition):
def eval(self, game, e, ctx):
# ★ 比较的是"原始费用"还是"实际支付的费用"?
return ctx.paid_cost > game.compute_cost(e)

这个”原始费用 vs 实际费用”的问题很真实。 炉石的裁定是:比较的是打出那张牌时实际支付的费用(含折扣)。所以《恶魔学者》把一张 10 费卡折扣到 2 费打出,不会腐蚀一张 5 费卡。这个裁定被玩家投诉过,但从实现角度它是自然的(ctx.paid_cost 就是实际扣的数)。

腐蚀的工程模式:手牌内变形(法术石的后代),加一次性触发。


第 21 章 2021:狂乱、任务链、可交易

21.1 贫瘠之地(2021.03)——狂乱、阶级法术、法术学派

狂乱(Frenzy)

文本:该随从第一次受到伤害但未死亡时触发。

class Keyword_Frenzy:
@staticmethod
def make(action):
return TriggerDef(
on=Event.DAMAGE,
source_only=False,
condition=Cond.target_is_self() & Cond.survived(),
action=action,
once=True,
)

class Survived(Condition):
def eval(self, game, e, ctx):
return ctx.amount > 0 and not ctx.fatal

它是激怒(Enrage)的正确替代品

激怒(2014-2018)狂乱(2021-)
范式光环(持续条件)触发(一次性事件)
治疗后效果消失效果保留
重算成本每次状态变化
沉默移除光环移除触发器
可预测性差(玩家常忘了它会消失)

这个替换是整份文档里我最想强调的一条经验:当一个机制用”持续条件”表达很别扭时,试试用”一次性事件”表达。事件驱动比状态驱动便宜得多,也好理解得多。

法术学派(Spell School)

新增 Tag.SPELL_SCHOOL,六种:火焰/冰霜/自然/暗影/神圣/奥术(后来加了《精魂》)。

零引擎改动,纯粹是给 Selector 加了一个筛选维度。但它打开了巨大的设计空间(”你的火焰法术伤害 +2″),且追溯适用于所有老卡——2014 年的《火球术》自动获得了 FIRE 学派。

这是数据驱动的另一个胜利:给旧数据打新标签,旧卡自动获得新机制的兼容性。

阶级法术(Ranked Spell)

文本:如果你有 X 个法力水晶,效果升级。

RANKED_SPELL = CardDef(
card_id="BAR_064", cost=1,
spell=Cond3(
(Cond.mana_crystals_at_least(10), StrongEffect()),
(Cond.mana_crystals_at_least(5), MediumEffect()),
(Always(), WeakEffect()),
),
)

纯条件分支,零新基础设施。设计上它解决了”低费卡后期没用”的问题。

21.2 暴风城下课(2021.08)——任务链、可交易

任务链(Questline)

文本:任务的三阶段版本。

@dataclass(frozen=True)
class QuestlineDef:
stages: tuple[QuestStage, ...]

@dataclass(frozen=True)
class QuestStage:
trigger_event: Event
condition: "Condition"
required: int
reward: "Action" # 中间阶段的奖励
next_card_id: str | None # 下一阶段的卡

class QuestlineProgress(Action):
def run(self, game, ctx):
q = ctx.source
q[Tag.QUEST_PROGRESS] += 1
stage = q.defn.questline.stages[q[Tag.QUESTLINE_STAGE]]
if q[Tag.QUEST_PROGRESS] >= stage.required:
stage.reward.run(game, ctx)
if stage.next_card_id:
# ★ 原地变形成下一阶段(保持在奥秘区,保持 ENTITY_ID)
transform(q, stage.next_card_id)
q[Tag.QUEST_PROGRESS] = 0
q[Tag.QUESTLINE_STAGE] += 1
game.event_bus.unregister_all(q)
game.event_bus.register_card(q)
else:
game.zones.move(q, Zone.GRAVEYARD)

任务链的价值在于它复用了三样东西:任务的注册机制、法术石的手牌变形、迦拉克隆的阶段升级。新增代码约 40 行。 这是”关键词是积木的组合”的最好证明。

可交易(Tradeable)★

文本:花费 1 点法力值,将该牌置入牌库并抽一张牌。

def trade_card(game, card):
pid = card[Tag.CONTROLLER]
p = game.player(pid)
if p[Tag.MANA_AVAILABLE] < 1:
return False
if not card.has(Tag.TRADEABLE):
return False
p[Tag.MANA_AVAILABLE] -= 1
# ★ 洗入牌库随机位置,不是牌库顶
game.zones.move(card, Zone.DECK)
game.shuffle_deck(pid)
game.draw(pid, 1)
game.emit(Event.CARD_TRADED, entity=card)
# ★ 交易不算"打出一张牌",不触发连击、不触发"使用一张牌后"

工程意义:可交易是第一个”非出牌”的手牌操作。之前手牌里的卡只有两个出路:打出、弃掉。可交易加了第三个,这要求:

  1. UI 增加一个手势(拖到牌库)
  2. 服务器增加一个 action 类型
  3. 网络协议增加一个消息
  4. AI 的动作空间增加一维

第 4 点值得展开:每增加一个玩家可执行的动作类型,AI 的搜索空间就乘以一个因子。可交易让每张可交易牌都多了一个分支。这就是为什么设计师对”新动作类型”非常谨慎——2021 年至今只加了三个(交易、地标使用、准备)。

21.3 奥特兰克山谷(2021.12)——荣誉击杀

荣誉击杀(Honorable Kill)

文本:造成恰好足够消灭一个随从的伤害时触发。

class HonorableKillCond(Condition):
def eval(self, game, e, ctx):
return ctx.amount == ctx.target_health_before and ctx.fatal

完全复用超杀的基础设施target_health_before 字段),新增代码 3 行。

它是一个”技巧型“关键词——要求玩家做算术。设计上很聪明(增加了 skill ceiling 而不增加复杂度),工程上零成本。


第 22 章 2022:巨型与地标

22.1 沉没之城(2022.04)——打捞、巨型

打捞(Dredge)

文本:查看你牌库底的三张牌,将其中一张置于牌库顶。

class Dredge(Action):
async def run(self, game, ctx):
deck = game.zones.get(ctx.controller, Zone.DECK)
if not deck: return
bottom = deck[:3] # ★ 牌库底 = list 的开头
chosen = await game.ask_choice(ctx.controller, bottom, kind="DREDGE")
deck.remove(chosen)
deck.append(chosen) # 移到牌库顶
game.zones._reindex(deck)

打捞的设计价值:它是”信息型随机控制“。你不能凭空生成资源,但你能操纵抽牌顺序。这比 Discover 的方差低得多,且不产生额外资源,所以在平衡上更安全。

注意”牌库顶/底”的方向约定。 我在代码里用 deck.pop() 抽牌,所以 list 的末尾是牌库顶。这个约定必须全局一致,否则会出现”打捞放到了牌库底”这种 bug。真实引擎里我建议用显式的 deck.top() / deck.bottom() 方法而不是裸索引。

巨型(Colossal +X)★

文本:该随从入场时,附带 X 个身体部件。

class Keyword_Colossal:
@staticmethod
def on_summon(game, main: Entity):
parts = main.defn.colossal_parts # 如 ("TSC_646t", "TSC_646t2")
pos = main[Tag.ZONE_POSITION]
# ★ 部件的召唤顺序与位置有明确规则:
# 第一个部件放主体左侧,第二个放右侧,交替
left_parts = parts[0::2]
right_parts = parts[1::2]
for p in reversed(left_parts):
game.summon(p, main[Tag.CONTROLLER], position=pos - 1)
for p in right_parts:
game.summon(p, main[Tag.CONTROLLER],
position=main[Tag.ZONE_POSITION] + 1)

巨型的陷阱

  1. 场地不够时,主体优先,部件能放几个放几个
  2. 部件是独立随从,可以被单独消灭
  3. 部件死亡不影响主体
  4. 主体死亡不影响部件
  5. 部件通常有 UNTOUCHABLE 或特殊限制(如”不能攻击”)
  6. 复制主体不会复制部件CopyMode.BASE),除非是特殊卡

工程意义:巨型是”一张卡对应多个实体“的第一个正式关键词。它要求 SETASIDE 区能同时容纳多个待召唤实体,且召唤顺序与位置的计算必须精确。

2026 年的《巨型》回归 + 《先驱》升级机制,把这个基础设施用到了新高度。

22.2 纳斯利亚堡的悬案(2022.08)——地标、灌注

地标(Location)★

文本:一种新的卡牌类型,放在战场上,每回合可以使用一次,有耐久度。

class CardType(IntEnum):
...
LOCATION = 9

def use_location(game, loc: Entity):
if loc[Tag.LOCATION_COOLDOWN] > 0:
return False # 本回合已用过
if loc[Tag.DURABILITY] <= 0:
return False
loc[Tag.LOCATION_COOLDOWN] = 1
loc[Tag.DURABILITY] -= 1
game.run(loc.defn.location_effect, source=loc, ctx=...)
if loc[Tag.DURABILITY] <= 0:
loc[Tag.TO_BE_DESTROYED] = 1 # 走正常死亡结算,会触发亡语
return True

# begin_turn 时重置
for loc in game.locations(pid):
loc[Tag.LOCATION_COOLDOWN] = 0

地标是 2022 年最大的引擎改动,因为它是八年来第一个新卡牌类型。影响面:

系统改动
战场地标占战场格子,与随从共享 7 格上限
目标地标不能被攻击,可以被法术指向(大部分)
死亡地标”耗尽”走死亡结算,可以有亡语
光环地标能提供光环
动作空间新增”使用地标”动作
AI搜索树增加一层
UI新的卡框、新的交互手势

推断:暴雪能在第 8 年加一个新卡牌类型而不重写引擎,正是因为”一切皆实体 + CARDTYPE 是 tag”这个设计。如果卡牌类型是 Python 的类继承,加一个类型要改几十处 isinstance 判断。

灌注(Infuse)

文本:在 X 个友方随从死亡后,该牌在你手中升级。

INFUSE = TriggerDef(
on=Event.DEATH,
zones=frozenset({Zone.HAND}),
condition=Cond.dead_is_friendly_minion(),
action=InfuseProgress(),
)

class InfuseProgress(Action):
def run(self, game, ctx):
c = ctx.source
c[Tag.INFUSE_PROGRESS] += 1
if c[Tag.INFUSE_PROGRESS] >= c.defn.infuse_required:
transform(c, c.defn.infuse_upgraded_id)

又一个手牌内成长。注意它订阅的是 DEATH 事件而且作用域是 HAND——这就是 TriggerDef.zones 这个字段的价值,同一套机制表达了完全不同的卡。

22.3 冰霜王座之march(2022.12)——死亡骑士、符文、尸块、法力渴求

符文系统(Rune)

归类:构筑期约束,运行时无感知。

@dataclass(frozen=True)
class RuneCost:
blood: int = 0
frost: int = 0
unholy: int = 0

def deck_is_legal(deck: list[CardDef], hero_runes: RuneCost) -> bool:
for card in deck:
r = card.rune_cost
if (r.blood > hero_runes.blood or r.frost > hero_runes.frost
or r.unholy > hero_runes.unholy):
return False
return True

它完全不影响对局引擎——这是个好的设计边界。构筑约束应该在构筑期解决,不要渗透进对局逻辑。

尸块(Corpse)

归类:新资源类型(第二个,第一个是法力值)。

# 玩家级 tag
Tag.CORPSES

# 随从死亡时自动 +1
TriggerDef(on=Event.DEATH, condition=Cond.dead_is_friendly_minion(),
action=GainCorpses(1))

class SpendCorpses(Action):
def __init__(self, n): self.n = n
def run(self, game, ctx):
p = game.player(ctx.controller)
p[Tag.CORPSES] = max(0, p[Tag.CORPSES] - self.n)

新增资源类型的工程代价

  1. 一个玩家级 tag(便宜)
  2. UI 显示(中等)
  3. 卡牌的”可用性”判定要考虑尸块(can_play 增加一个条件)
  4. AI 的估值函数要加一项(这是真正的成本)

法力渴求(Manathirst X)

文本:如果你本局中拥有过 X 个法力水晶,效果升级。

class ManathirstCond(Condition):
def __init__(self, n): self.n = n
def eval(self, game, e, ctx):
# ★ 是"本局曾经拥有过",不是"当前拥有"
return game.player(ctx.controller).max_mana_ever >= self.n

注意 max_mana_ever 这个字段。它必须单独维护,因为当前法力水晶可能被《飞刀》类效果减少。又一个”历史状态”的例子——引擎需要记住的东西越来越多。


第 23 章 2023:终曲、泰坦、挖掘、锻造

23.1 传说节日(2023.04)——终曲

终曲(Finale)

文本:如果这张牌消耗了你所有的法力值,触发额外效果。

class FinaleCond(Condition):
def eval(self, game, e, ctx):
# ★ 在出牌流程中,扣费之后立刻判断
return game.player(ctx.controller)[Tag.MANA_AVAILABLE] == 0

实现位置很关键:必须在扣费之后、效果结算之前判断,而且要在效果内部保存这个布尔值(因为效果本身可能改变法力值,如《硬币》)。

def play_card(self, card, ...):
...
p[Tag.MANA_AVAILABLE] -= cost
ctx.finale = (p[Tag.MANA_AVAILABLE] == 0) # ★ 快照
...

又一次”快照”。这份文档里”快照”出现了太多次,我把它提炼成一条核心经验放在第七部分。

23.2 泰坦(2023.08)——泰坦、锻造

泰坦(Titan)★

文本:该随从有三个技能,每回合可以使用一个,全部使用完后才能攻击。

class Keyword_Titan:
@staticmethod
def use_ability(game, titan: Entity, index: int):
if titan[Tag.TITAN_ABILITY_USED] & (1 << index):
return False # 位掩码,这个技能用过了
if titan[Tag.EXHAUSTED] and titan[Tag.NUM_TURNS_IN_PLAY] == 0:
pass # 泰坦技能可以在召唤回合使用(不受召唤失调影响)
titan[Tag.TITAN_ABILITY_USED] |= (1 << index)
game.run(titan.defn.titan_abilities[index], source=titan)
return True

@staticmethod
def can_attack(titan) -> bool:
return titan[Tag.TITAN_ABILITY_USED] == 0b111 # 三个都用完

位掩码存三个布尔值是个小技巧,但它体现了一个原则:tag 是整数,所以你可以在一个 tag 里塞多个布尔位。这在需要网络同步的场景下能省带宽。

工程影响:泰坦又增加了一个动作类型(”使用泰坦技能”),且这个动作有三个变体。AI 的分支因子再次上升。

锻造(Forge)

文本:花费 2 点法力值,从牌库中丢弃这张牌以升级它。

def forge_card(game, card):
pid = card[Tag.CONTROLLER]
p = game.player(pid)
if p[Tag.MANA_AVAILABLE] < 2 or not card.has(Tag.FORGE):
return False
p[Tag.MANA_AVAILABLE] -= 2
# ★ 洗入牌库 → 抽一张 → 原卡变成升级版
upgraded = card.defn.forge_upgraded_id
game.zones.move(card, Zone.DECK)
transform(card, upgraded)
game.shuffle_deck(pid)
game.draw(pid, 1)

锻造 = 可交易 + 变形。零新基础设施,完全是两个已有机制的组合。这就是成熟引擎的样子:新关键词的实现成本随时间递减

23.3 荒芜之地的角逐(2023.11)——挖掘、快速射击

挖掘(Excavate)★

文本:获得一个宝藏。你挖掘的次数越多,宝藏越好。

EXCAVATE_TIERS = {
1: ["TIER1_A", "TIER1_B", "TIER1_C"],
2: ["TIER2_A", ...],
3: ["TIER3_A", ...],
4: ["LEGENDARY_TREASURE_BY_CLASS"], # 每职业一张
}

class Excavate(Action):
async def run(self, game, ctx):
p = game.player(ctx.controller)
p.excavate_tier = min(4, p.excavate_tier + 1)
pool = EXCAVATE_TIERS[p.excavate_tier]
if p.excavate_tier == 4:
cid = LEGENDARY_TREASURE[p.klass]
p.excavate_tier = 0 # ★ 挖到传说后重置
else:
cid = game.rng.choice(pool)
game.add_to_hand(ctx.controller, cid)

挖掘 = 跨回合计数器 + 分层随机池。它的设计价值在于给随机性一个可预测的进度条:你知道再挖两次就有传说宝藏,这让 RNG 变成了资源管理。

快速射击(Quickdraw)

文本:如果在你抽到这张牌的回合使用它,触发额外效果。

class QuickdrawCond(Condition):
def eval(self, game, e, ctx):
return e[Tag.DRAWN_ON_TURN] == game.turn

# draw() 里记录
def draw(self, pid, n=1):
...
card[Tag.DRAWN_ON_TURN] = self.turn

又一个历史状态字段。 到 2023 年,引擎需要维护的”历史”已经包括:

  • 本局最大法力值(Manathirst)
  • 上回合是否使用过元素/某类型(元素、同源)
  • 本回合是否打出过牌(连击)
  • 本局使用过多少法术(尤格)
  • 挖掘等级
  • 抽到的回合(快速射击)
  • 本局死亡过的友方随从数(灌注、复活类)

设计建议:不要为每个机制加一个专用字段。用一个统一的 GameStats 结构,按”事件计数器”的方式组织:

@dataclass
class PlayerStats:
counters: dict[str, int] = field(default_factory=lambda: defaultdict(int))
per_turn: dict[str, int] = field(default_factory=lambda: defaultdict(int))
last_turn: dict[str, int] = field(default_factory=lambda: defaultdict(int))

def bump(self, key, n=1):
self.counters[key] += n
self.per_turn[key] += n

def rotate(self):
self.last_turn = dict(self.per_turn)
self.per_turn.clear()

然后所有”历史条件”变成统一查询:

Cond.stat_at_least("spells_cast", 6)          # 任务
Cond.last_turn_stat_at_least("elemental_played", 1) # 元素/同源
Cond.turn_stat_at_least("cards_played", 1) # 连击

这一条重构能省掉后面十年的一半新增字段。


第 24 章 2024:迷你化、旅客、星舰

24.1 维兹班的工坊(2024.03)——迷你化

迷你化(Miniaturize)

文本:战吼:将一张 1 费的 1/1 迷你版洗入你的牌库。

MINIATURIZE = Seq(
ShuffleIntoDeck(Ctx.SOURCE_MINI_ID),
)

纯组合,零新基础设施。迷你版是一张独立的卡定义。

24.2 天空邮轮的悖论(2024.07)——旅客

旅客(Tourist)★

文本:你的牌库中可以包含另一个职业的 X 卡。

def deck_is_legal_with_tourist(deck, hero_class) -> bool:
tourists = [c for c in deck if c.tourist_class]
allowed_extra = {t.tourist_class for t in tourists}
for c in deck:
if c.klass == hero_class or c.klass == "NEUTRAL":
continue
if c.klass in allowed_extra and c.is_tourist_eligible:
continue
return False
return True

又一个构筑期机制,对局引擎完全无感知。这是个好设计——把复杂度推到构筑期,让对局引擎保持简单

但它有一个运行时后果:“发现一张你职业的卡”这个查询变了。如果你带了盗贼旅客,你的法师能不能发现盗贼卡?答案是(对于旅客允许的那部分)。所以 CardPool.build() 要考虑旅客:

def build(self, game, ctx):
klass = game.player(ctx.controller).klass
classes = {klass}
classes |= game.player(ctx.controller).tourist_classes # ★
return game.card_index.query(klass=classes, ...)

24.3 浩瀚之外(2024.11)——星舰

星舰(Starship)★

文本:星舰零件可以装配到星舰上。装配足够零件后,发射星舰。

class StarshipSystem:
"""星舰是一个'容器实体',零件是附加在它上面的附魔。"""

@staticmethod
def install_part(game, starship: Entity, part: Entity):
# 零件的属性叠加到星舰上(和磁力几乎一样!)
ench = make_enchantment_from(part)
apply_enchantment_entity(game, starship, ench)
starship.installed_parts.append(part[Tag.CARD_ID])
game.remove_entity(part)

@staticmethod
def launch(game, starship: Entity):
# 发射:星舰变成一个真正的随从,获得所有零件的能力
starship[Tag.LAUNCHED] = 1
starship[Tag.EXHAUSTED] = 0 # 可以立即攻击
for part_id in starship.installed_parts:
for kw in CARD_DB[part_id].keywords:
starship[TAG_OF_KEYWORD[kw]] = 1

星舰 = 磁力 + 泰坦 + 巨型的组合。它需要:

  • 磁力的”属性与能力合并”
  • 泰坦的”多阶段使用”
  • 巨型的”一张卡多个实体”

新增代码很少,因为三套基础设施都在。 这是我在整份编年史里观察到的最清晰的趋势:2014-2018 每个新关键词都要动引擎,2019 之后绝大多数新关键词是已有积木的重组。


第 25 章 2025:灌注、黑暗恩赐、同源、倒带、传奇

25.1 翡翠梦境(2025.03)——灌注、黑暗恩赐

来源:Into the Emerald Dream 官方公告esports.gg 机制解析

灌注(Imbue)★

文本:使用一张灌注牌后,你的英雄技能升级。每次灌注继续升级。

适用职业:德鲁伊、猎人、法师、圣骑士、牧师、萨满。

class Imbue(Action):
def run(self, game, ctx):
p = game.player(ctx.controller)
p.imbue_count += 1
hp = p.hero_power
if p.imbue_count == 1:
# 第一次:替换成灌注版英雄技能
game.replace_hero_power(ctx.controller, IMBUE_HERO_POWER[p.klass])
else:
# 后续:升级(累加 tag)
hp[Tag.IMBUE_LEVEL] += 1

这是 2019 年《探底》(Invoke)的直系后代——同样的”计数器 + 英雄技能升级”模式,间隔六年后复用。工程上几乎零成本。

黑暗恩赐(Dark Gift)

文本:发现一个强化,附加给一个随从。

适用职业:死亡骑士、恶魔猎手、盗贼、术士、战士。

DARK_GIFTS = [
("GIFT_TAUNT", Buff(atk=0, hp=2) + GiveKeyword("TAUNT")),
("GIFT_RUSH", Buff(atk=2, hp=0) + GiveKeyword("RUSH")),
("GIFT_DR", GiveDeathrattle(Draw(1))),
...
]

class DarkGift(Action):
async def run(self, game, ctx):
picks = game.rng.sample(DARK_GIFTS, 3)
chosen = await game.ask_choice(ctx.controller, picks, kind="DARK_GIFT")
await chosen.action.run(game, ctx)

这是《适应》(2017)的直接复刻:固定池 + 三选一 + 附加给随从。八年前的基础设施,一行不改地复用。

抉择全职业化

2025 年把《抉择》从德鲁伊专属扩展到所有职业,并引入”抉择多选“。引擎侧:ctx.chooseint 变成 list[int]resolve_choose_one 循环执行。约 15 行改动。

25.2 失落的安戈洛之城(2025.07)——同源、任务回归

来源:esports.gg 报道官方页面

同源(Kindred)★

文本:如果你在上个回合使用过相同随从类型或法术学派的牌,获得额外效果。

class KindredCond(Condition):
def __init__(self, kind: str, value: str):
self.kind, self.value = kind, value # kind ∈ {"RACE", "SPELL_SCHOOL"}
def eval(self, game, e, ctx):
stats = game.player(ctx.controller).stats
key = f"played_{self.kind.lower()}_{self.value}"
return stats.last_turn.get(key, 0) > 0

# 出牌时记录
def _record_play_stats(self, card):
stats = self.player(card[Tag.CONTROLLER]).stats
stats.bump("cards_played")
if card[Tag.CARDRACE]:
stats.bump(f"played_race_{card[Tag.CARDRACE]}")
if card[Tag.SPELL_SCHOOL]:
stats.bump(f"played_spell_school_{card[Tag.SPELL_SCHOOL]}")

如果你在 2017 年就按 23.3 节的建议做了统一的 PlayerStats,同源的实现是零行引擎代码——只是一个新的 Condition。这是我在这份文档里能给出的最有说服力的”提前抽象”论据:2017 年《元素》引入的”上回合是否用过某类型”,八年后被《同源》推广到所有类型和学派。

故事卡与地图卡

  • 故事卡:引用过去资料片的任务,触发对应效果。纯数据。
  • 地图卡:发现一张牌,如果本回合打出,再发现一张。这是”链式发现”,实现上是 Discover(then=MarkForChain()) + 一个回合内触发器。
MAP_CARD = Seq(
Discover(pool=...),
RegisterTemporaryTrigger(
TriggerDef(on=Event.PLAY_CARD,
condition=Cond.is_the_discovered_card(),
action=Discover(pool=...),
once=True, expires=Duration.END_OF_TURN),
),
)

“临时触发器”(本回合有效的动态注册)是这里唯一的新基础设施,约 25 行。

25.3 时光之外(2025.11)——倒带、传奇

来源:esports.gg 报道官方公告

倒带(Rewind)★

文本:重新使用这张牌,以获得不同的随机结果。

这是炉石第一个正式的”撤销并重做”机制,工程上非常有意思。

class Rewind(Action):
"""重放一张已结算的随机卡,撤销原结果。"""
async def run(self, game, ctx):
card = ctx.source
# ★ 必须能"撤销"上一次的效果
game.undo_to_checkpoint(card.rewind_checkpoint)
# 重新消费 RNG(★ 关键:必须是新的随机数,不能重放同一序列)
await game.replay_card_effect(card)

这需要引擎支持”状态检查点 + 回滚”,这是一个重量级能力。有三种可能的实现(推断,官方未公开):

方案做法代价
A. 全状态快照出牌前深拷贝整个 GameState,倒带时还原内存大,但绝对正确
B. 反向操作日志记录每个 tag 变化的 (entity, tag, old, new),倒带时反向重放内存小,但”创建实体”等操作难以完美撤销
C. 限制作用域只允许对”效果范围明确且局部”的卡使用倒带最简单,但限制了设计空间

我的推断是暴雪用了 C + B 的组合:倒带只出现在”生成随机卡/召唤随机随从”这类效果局部且可枚举的卡上,撤销时只需要删掉生成的实体、还原 RNG 之外的状态。文档里描述的例子(”施放不稳定传送门得到幽灵,倒带重摇”)完全符合这个特征。

如果你要在自己的引擎里做类似机制,我强烈建议方案 A(全快照)+ 结构共享(persistent data structure)。因为方案 B 的”撤销”在有触发链的情况下几乎不可能做对——你撤销了一个召唤,但那个召唤触发的《飞刀杂耍者》伤害怎么撤销?

倒带对确定性随机的影响:倒带必须消费新的随机数,否则它就毫无意义。这意味着 RNG 序列不能”按操作索引确定”,只能是”顺序消费的流”。这对回放系统是个约束:回放必须记录每次 RNG 调用的结果,而不是只记录 seed。

传奇(Fabled)

文本:将一个传奇英雄加入牌库时,自动附带若干相关传说卡。

FABLED_BUNDLES = {
"SYLVANAS_RANGER_GENERAL": ["RANGER_CAPTAIN_ALLERIA", "RANGER_INITIATE_VEREESA"],
}

def build_deck_with_fabled(cards: list[str]) -> list[str]:
out = list(cards)
for c in cards:
out.extend(FABLED_BUNDLES.get(c, []))
return out

又一个构筑期机制,零对局引擎改动。但它有一个 UX 后果:牌库不再是 30 张,而是 30 + N。所以疲劳计算、”你的牌库中”类查询、《雷诺》类”无重复”判定全都要考虑这些附带卡。


第 26 章 2026:先驱、碎裂、准备

26.1 大灾变(2026.03)——先驱、碎裂

来源:HearthstoneTopDecks 资料片指南Hearthstone Wiki

先驱(Herald)★

文本:(死亡骑士、恶魔猎手、盗贼、萨满、术士、战士)使用先驱牌时:召唤一个你职业巨型随从的”士兵”,并推进巨型/死亡之翼的升级进度。每 2 次先驱触发一次升级,共两次升级。

class Herald(Action):
def run(self, game, ctx):
p = game.player(ctx.controller)
p.herald_count += 1

# 1. 召唤士兵(继承巨型某个部件的能力)
colossal_id = CLASS_COLOSSAL[p.klass]
soldier_id = CLASS_HERALD_SOLDIER[p.klass]
soldier = game.summon(soldier_id, ctx.controller)
if soldier:
self._apply_upgrade_level(game, soldier, p.herald_upgrade_level)

# 2. 每 2 次推进一级(最多 2 级)
if p.herald_count % 2 == 0 and p.herald_upgrade_level < 2:
p.herald_upgrade_level += 1
self._upgrade_all(game, ctx.controller, p.herald_upgrade_level)

@staticmethod
def _upgrade_all(game, pid, level):
"""升级手牌/牌库/战场上的巨型、死亡之翼,以及已召唤的士兵"""
for e in game.all_entities_of(pid):
if e[Tag.HERALD_UPGRADEABLE]:
transform_keeping_position(e, e.defn.herald_upgrades[level - 1])

先驱 = 探底(2019)+ 灌注(2025)+ 巨型(2022)。三个已有模式的组合:跨区域计数器、阶段性升级、多实体主体。

工程上唯一的新东西是”跨区域批量升级”——要同时升级手牌、牌库、战场、甚至墓地里的实体。这需要 all_entities_of(pid) 能遍历所有区域,且 transform 能在任何区域工作(法术石在 2017 年就做到了)。

碎裂(Shatter)★

文本:(德鲁伊、猎人、法师、圣骑士、牧师)当你抽到这张牌时,它分裂成两个较弱的版本,分别放在你手牌的两端。当它们相邻时,合并回完整版本。

这是我见过的最”位置敏感”的关键词,它把 2020 年《流放》引入的”手牌位置即状态”推到了极致。

class Keyword_Shatter:
@staticmethod
def on_drawn(game, card):
"""抽到时分裂"""
pid = card[Tag.CONTROLLER]
hand = game.zones.get(pid, Zone.HAND)
left_id, right_id = card.defn.shatter_halves
game.remove_entity(card)

left = game.create_entity(left_id, pid, zone=Zone.SETASIDE)
right = game.create_entity(right_id, pid, zone=Zone.SETASIDE)
left.shatter_pair_id = right.id
right.shatter_pair_id = left.id
left.shatter_origin = right.shatter_origin = card[Tag.CARD_ID]

# ★ 一个放最左,一个放最右
game.zones.move(left, Zone.HAND, position=0)
game.zones.move(right, Zone.HAND, position=len(hand))

@staticmethod
def check_merge(game, pid):
"""每次手牌变化后检查是否有相邻的碎片对"""
hand = game.zones.get(pid, Zone.HAND)
for i in range(len(hand) - 1):
a, b = hand[i], hand[i + 1]
if getattr(a, "shatter_pair_id", None) == b.id:
pos = i
origin = a.shatter_origin
game.remove_entity(a)
game.remove_entity(b)
merged = game.create_entity(origin, pid, zone=Zone.SETASIDE)
game.zones.move(merged, Zone.HAND, position=pos)
game.emit(Event.SHATTER_MERGED, entity=merged)
return True
return False

check_merge 必须在每次手牌变化后调用:抽牌、出牌、生成牌、弃牌、交易、准备……这是一个典型的”不变式维护“问题。

正确的做法不是在每个手牌操作后手动调用,而是把它做成手牌区域的一个不变式钩子

class ZoneManager:
ZONE_INVARIANTS = {
Zone.HAND: [Keyword_Shatter.check_merge],
}

def move(self, e, to_zone, position=None):
...
# 移动完成后,检查受影响区域的不变式
for zone in {src, to_zone}:
for inv in self.ZONE_INVARIANTS.get(zone, ()):
while inv(self.game, pid): # 循环直到稳定
pass

这是引擎设计的一条重要经验:与其在 N 个调用点手动维护不变式,不如在状态变化的唯一入口做统一检查。前者随着卡池增长会漏掉某个路径,后者永远正确。

碎裂的策略深度:因为手牌是从右侧添加的,你打出中间的牌会让两半靠近。这创造了一个”手牌管理”的新维度——玩家要主动规划出牌顺序来合并碎片。同时,它也惩罚了满手牌(碎片被隔得很远)。

碎裂的实现陷阱

  1. 两半在手牌上限计算里算两张牌
  2. 单独打出一半是合法的(较弱版本),另一半会永久留下
  3. 复制一半 → 复制品的 shatter_pair_id 指向已经配对的那个,会导致错误合并(需要在复制时清除配对关系)
  4. 被对手偷走一半(《心灵尖啸》类)→ 配对断裂

26.2 紫罗兰监狱越狱(2026.07)——准备、破规传说

来源:Hearthstone Wiki: Prepare官方公告

准备(Prepare)★

文本:拖到你的牌库以消耗剩余法力值。下个回合,这张牌的费用降低你消耗的量(额外再减 1)。

def prepare_card(game, card: Entity, mana_to_spend: int) -> bool:
pid = card[Tag.CONTROLLER]
p = game.player(pid)

if not card.has(Tag.PREPARE): return False
if card[Tag.PREPARED]: return False # 只能准备一次
if mana_to_spend < 1: return False
if p[Tag.MANA_AVAILABLE] < mana_to_spend: return False

current_cost = game.compute_cost(card)
# ★ 消耗量不超过把费用减到 0 所需
mana_to_spend = min(mana_to_spend, max(0, current_cost - 1))
if mana_to_spend < 1: return False

p[Tag.MANA_AVAILABLE] -= mana_to_spend
card[Tag.PREPARE_DISCOUNT] = mana_to_spend + 1 # 额外 +1
card[Tag.PREPARED] = 1
card[Tag.LOCKED_UNTIL_TURN] = game.turn + 1 # ★ 本回合不能再打出
game.emit(Event.CARD_PREPARED, entity=card, amount=mana_to_spend)
# 卡牌立即回到手牌原位置(视觉上是"拖出去又弹回来")
return True

准备的规则细节(来自 Wiki):

  1. 准备不算”打出一张牌” → 不触发连击、不触发”使用一张牌后”、不推进任务
  2. 卡牌回到手牌原位置 → 不影响流放/碎裂的位置判定
  3. 需要至少 1 点法力值
  4. 消耗量不超过把费用减到 0 所需的量
  5. 每张牌只能准备一次
  6. 折扣在被复制或被偷取时保留
  7. 折扣在被洗入牌库打出后返回手牌时重置
  8. 准备后本回合锁定在手中

工程分析:准备是第四个非出牌手牌动作(前三个:打出、弃掉、交易),也是第二个消耗法力但不打出牌的动作。

它的第 6、7 条规则揭示了一个实现选择:PREPARE_DISCOUNT附魔而不是纯 tag。因为:

  • 复制保留 → 说明它跟着实体走(FULL 复制模式会复制附魔)
  • 洗入牌库重置 → 说明有一个”离开手牌时移除”的持续时间
ENCH_PREPARE_DISCOUNT = CardDef(
card_id="VH_PREPAREe", card_type="ENCHANTMENT",
duration=Duration.WHILE_IN_HAND, # 离开手牌即失效
enchant_tags={Tag.COST: -1}, # amount 动态设置
)

准备的设计意图:它解决了炉石十二年的一个老问题——回合末的零散法力值浪费。在此之前,剩 2 点法力没牌可打就是纯浪费。准备把这些碎片资源变成了未来的折扣。

从平衡角度,它是一个“平滑曲线”机制:让手里的高费卡提前一到两回合上场,但代价是提前投入。这类机制在设计上很安全(不产生额外资源,只是时间转移),所以我预期它会成为常青机制。

破规传说(Rulebreaker Legendaries)

每个职业一张”扭曲核心规则”的传说随从。这是引擎设计的终极压力测试

从公开信息看,这类卡包括:

  • 在对手场上打出随从(伪装随从)——这要求 play_cardcontroller 参数能指向对手
  • 贿赂卡:给自己强力效果,同时给对手一点补偿

“在对手场上打出随从” 是一个真正的新能力。它要求:

def play_card(self, card, target=None, position=None,
summon_side: int | None = None): # ★ 新参数
pid = card[Tag.CONTROLLER]
side = summon_side if summon_side is not None else pid
...
ok = self.zones.move(card, Zone.PLAY, position, owner_side=side)
card[Tag.CONTROLLER] = side # ★ 控制权归对手
card[Tag.OWNER] = pid # ★ 但拥有者还是我

CONTROLLEROWNER 分离的设计(第 1 章就定义了)在这里终于兑现了全部价值。十二年前定义的一个 tag,让 2026 年的新机制不需要改引擎。

这是我在整份文档里最想强调的一条:好的建模的价值不在于它今天解决了什么问题,而在于它让十年后的你不需要重写。


第 27 章 关键词的六大范式:一个正交分解

看完六十个关键词,可以做一次归纳。所有炉石关键词都落入六个范式之一(或它们的组合):

范式一:静态标签(Static Tag)

定义:一个布尔或整数 tag,在查询时被规则消费。不主动做任何事。

关键词消费点
嘲讽is_legal_attack_target
冲锋 / 突袭can_attack / is_legal_attack_target
潜行攻击目标 + 法术目标
圣盾伤害管线
剧毒伤害后处理
吸血伤害后处理
风怒can_attack
冻结can_attack
免疫伤害管线 + 目标
不可选中目标合法性
复生死亡结算
法术伤害伤害计算
休眠全方位

实现成本:极低(一个 tag + 一处判断) 组合性:完美(tag 天然可叠加) 占比:约 25% 的关键词

设计经验:能做成静态标签的,就不要做成触发器。静态标签零运行时开销,且不会有时序问题。

范式二:触发器(Trigger)

定义:订阅一个事件,满足条件时执行一个 Action。

关键词事件条件一次性
战吼PLAY_CARDsource_only
亡语DEATHsource_only
励志HERO_POWER_USED己方
超杀DAMAGE我造成 & 溢出 & 我的回合
荣誉击杀DAMAGE我造成 & 恰好致死
狂乱DAMAGE打到我 & 我活着
法术迸发AFTER_SPELL己方
过量治疗OVERHEAL打到我
开局时GAME_START在牌库
抽到时施放DRAWsource_only
快速射击PLAY_CARD本回合抽到

实现成本:低(一个 TriggerDef) 组合性:好,但有时序问题 占比:约 35%

设计经验:事件上下文要携带尽可能多的信息(amounttarget_health_beforefatalpaid_cost),这样新关键词只需要加一个 Condition。

范式三:出牌流程钩子(Play Hook)

定义:在 play_card 的某个阶段插入逻辑,或者增加一个全新的手牌动作。

关键词钩子位置
过载打出后(资源)
连击打出前(条件分支)
抉择打出前(选项)
回响打出后(生成副本)
双生法术打出后(生成副本)
流放打出前(位置条件)
终曲扣费后(法力条件)
法力渴求打出前(历史条件)
可交易新动作
锻造新动作
准备新动作
使用地标新动作
泰坦技能新动作

实现成本:中(要改主流程) 组合性:差(钩子之间可能冲突) 占比:约 20%

设计经验“新动作类型”是最贵的关键词。它影响 UI、协议、AI 搜索空间。炉石十二年只加了五个,非常克制。

范式四:选择型(Choice)

定义:需要中断结算,等待玩家输入。

关键词池子数量
发现动态查询3
适应固定 103
打捞牌库底 31(全展示)
挖掘分层池1(随机,不选)
黑暗恩赐固定池3
抉择固定 21

实现成本:高(需要挂起/恢复机制) 组合性:差(不能嵌套) 占比:约 10%

设计经验:用 async/协程实现挂起点,不要用异常或显式状态机。并且为 AI 提供一个”立即决策”的实现。

范式五:状态机 / 计数器(Progression)

定义:跨回合累积一个计数,到阈值时升级或释放奖励。

关键词计数对象阶段数
任务事件次数1
任务链事件次数3
法术石事件次数2
阴谋回合数
探底使用次数2
腐蚀一次性条件1
灌注(Infuse)友方死亡数1-2
挖掘挖掘次数4
泰坦技能使用位掩码3
灌注(Imbue)使用次数
先驱使用次数2
星舰零件数可变

实现成本:中(需要跨区域的持久 tag + 变形) 组合性:好 占比:约 20%

设计经验:把”计数”和”升级”分离成两个 Action,前者复用,后者卡牌特定。所有的进度条机制都能用同一套 Progression 组件表达:

@dataclass(frozen=True)
class ProgressionDef:
counter_key: str # 统计哪个事件
thresholds: tuple[int, ...] # 各阶段阈值
on_stage: tuple["Action", ...] # 各阶段奖励
upgrade_to: tuple[str, ...] = () # 各阶段的变形目标
zones: frozenset[Zone] = frozenset({Zone.HAND, Zone.SECRET, Zone.PLAY})

这一个数据结构能表达上面十二个关键词。

范式六:多实体 / 合并(Composite)

定义:一张卡对应多个实体,或多个实体合并成一个。

关键词方向
磁力合并(N → 1)
巨型分裂(1 → N)
星舰合并 + 发射
碎裂分裂 + 合并
传奇构筑期分裂
抉择(子卡)定义期分裂

实现成本:高(要处理属性合并、能力转移、位置计算、沉默交互) 组合性:差(合并后的实体状态复杂) 占比:约 8%

设计经验:合并必须通过附魔实现,这样沉默/移除时能一次性回滚。绝对不要直接修改宿主的基础定义。

范式分布随时间的演化

2014  ████████████░░░░░░░░  静态标签为主
2015 ████████░░████░░░░░░ 触发器崛起
2016 ██████░░████░░██░░░░
2017 ████░░████████░░████ 选择型(发现)+ 状态机(任务)
2018 ████░░██████░░████░░
2019 ██░░░░████████████░░ 状态机成为主流
2020 ██░░░░████████░░████
2021 ██░░░░██████████████
2022 ░░░░░░████████░░████ 多实体(巨型)
2023 ░░░░░░████████████░░
2024 ░░░░░░████░░████████ 多实体(星舰)
2025 ░░░░░░████████████░░
2026 ░░░░░░████░░████████ 多实体(碎裂、先驱)

图例:静态标签 / 触发器 / 出牌钩子 / 选择型 / 状态机 / 多实体

趋势解读

  1. 静态标签在 2016 年后基本饱和。所有”简单的持续属性”都被用光了。
  2. 触发器保持稳定,因为事件空间在扩大(新增事件 = 新的触发机会)。
  3. 状态机在 2017 年后成为主力。它提供”跨回合的目标感”,是留存率的好朋友。
  4. 多实体在 2022 年后崛起。这是因为前面的空间用尽了,设计师必须去更复杂的方向。

给设计师的启示:关键词的复杂度是单调递增的,因为简单的设计空间会被先用完。所以引擎必须在早期就为复杂度留出余量。

给工程师的启示:如果你在做一个长期运营的卡牌游戏,从第一天就实现”多实体”和”状态机”的基础设施,即使你的初版卡牌用不上。因为等到第八年你需要它们时,改造成本会高十倍。


第五部分 疑难时序案例研究

这一部分挑十个真实存在过的裁定争议或历史 bug。 每个案例的价值不在于炉石怎么解决的,而在于它暴露了什么架构缺陷。


案例 1:两个不稳定的食尸鬼同时死亡

场面:你的场上有 A、B 两个《不稳定的食尸鬼》(1/3,亡语:对所有随从造成 1 点伤害),都已经受了 2 点伤害(剩 1 血)。对手打出《暴风雪》(对所有敌方随从造成 2 点伤害)。

问题:A 和 B 的亡语都触发吗?它们的伤害会互相打到吗?

答案:都触发;不会互相打到。

推演

1. 暴风雪:A[DAMAGE] 2→4,B[DAMAGE] 2→4。两者 current_health = 3-4 = -1
2. process_deaths 第一轮:
├─ 收集 dying = [A, B] (ENTITY_ID 排序)
├─ 全部快照
├─ 全部 move → GRAVEYARD ★ 在触发亡语之前
├─ emit(DEATH, A) → A 的亡语入队
├─ emit(DEATH, B) → B 的亡语入队
└─ resolve():
├─ A.亡语:Damage(所有随从, 1)
│ └─ 目标 = 当前 PLAY 区的随从 = 不含 A、B(已在墓地)
└─ B.亡语:Damage(所有随从, 1)
└─ 同上
3. process_deaths 第二轮:处理被亡语打死的随从

暴露的架构要求:死亡结算必须是批量的,”移入墓地”必须先于“触发亡语”,且所有死亡实体一起移动

反例:如果实现成”逐个处理死亡”(收集一个、处理一个),A 的亡语执行时 B 还在场上,B 会被再打一次(虽然它已经死了)——虽然结果可能一样,但如果 B 是《恩佐斯的宝箱》类会免死的实体,行为就完全不同了。


案例 2:暴风城勇士与沉默的致死交互

场面:你有一个《破碎残阳祭司》(3/2)和一个《暴风城勇士》(3/2,光环:你的其他随从 +1/+1)。祭司实际是 4/3。它受了 2 点伤害(剩 1 血)。

现在对手沉默了《暴风城勇士》。

问题:祭司会死吗?

答案:会。

推演

沉默前:祭司 HEALTH=2(基础) + 1(光环) = 3,DAMAGE=2,current = 1
沉默后:光环重算 → 祭司 HEALTH=2,DAMAGE=2,current = 0 → 死亡

这是”伤害标记”模型的必然后果。玩家的直觉是”血量降了但伤害也应该降”,但规则不是这样。

暴露的架构要求:光环重算之后必须调用 process_deaths。如果光环重算发生在死亡结算之外(比如在读取 tag 时惰性重算),就会出现”祭司血量是 0 但还活着”的幽灵状态。

def silence(game, e):
...
game.aura_bus.mark_dirty()
game.aura_bus.recompute() # 立即重算
game.process_deaths() # ★ 必须跟一次死亡结算

推广的规则任何可能改变生命值上限的操作之后,都必须跟一次死亡结算。这包括:沉默、变形、光环源离场、控制权变化、《王者祝福》被移除……


案例 3:亡语召唤的位置

场面:你的战场从左到右是 [小鬼, 蜘蛛坦克, 鱼人](蜘蛛坦克在位置 2,亡语:召唤一个 1/1 机械)。蜘蛛坦克死亡。

问题:召唤的机械在哪个位置?

答案:位置 2(原蜘蛛坦克的位置)。

为什么难:亡语执行时,蜘蛛坦克已经在墓地,战场变成了 [小鬼, 鱼人],鱼人的位置从 3 变成了 2。所以”原位置 2″这个信息必须在死亡瞬间快照

# 死亡快照里保存 position
snap.position = e[Tag.ZONE_POSITION] # = 2

# 亡语执行时
class SummonAtDeathPosition(Action):
def run(self, game, ctx):
pos = ctx.event.snapshot.position
game.summon(self.card_id, ctx.controller, position=pos - 1)

更难的变体:两个蜘蛛坦克同时死亡(位置 2 和 3)。

答案:第一个(ENTITY_ID 小的)先召唤到位置 2,第二个召唤到位置 3。但因为第一个已经占了位置 2,第二个实际会被挤到位置 3——这恰好是对的,因为它原本就在 3。

但如果它们的位置是 2 和 5,中间隔着别的随从?实测炉石的行为是:尽量在快照位置召唤,被占了就往右挪

暴露的架构要求:位置是有序容器的索引,不是绝对坐标。所有”在 X 位置召唤”的逻辑都要处理”位置已被占用”的情况。


案例 4:奥秘的触发顺序与相互作用

场面:对手有两个奥秘:《爆炸陷阱》(当你的英雄受到攻击时,对所有敌人造成 2 点伤害)和《寒冰陷阱》(当一个敌方随从攻击时,将其移回手牌并使其费用增加 2 点)。

你用一个 2/1 随从攻击对方英雄。

问题:两个奥秘都触发吗?谁先?

答案:都触发,按 ENTITY_ID 顺序(先放的先触发)。

推演(假设爆炸陷阱先放)

attack(我的2/1, 对方英雄)
├─ emit(PROPOSED_ATTACK)
│ ├─ 匹配:[爆炸陷阱, 寒冰陷阱] (按 ENTITY_ID)
│ └─ 入队
└─ resolve()
├─ 爆炸陷阱:揭示 → 对所有敌人(含我的 2/1)造成 2 伤 → 我的 2/1 死亡待定
└─ 寒冰陷阱:揭示 → 把攻击者移回手牌
★ 但攻击者已经"死亡待定"了,怎么办?

真实行为:寒冰陷阱依然触发并消耗,但因为攻击者已经被标记死亡,效果失败(或者说,它被移回手牌然后立刻…不,实测是移回手牌成功,因为死亡结算还没发生)。

这个交互在历史上出过 bug,现代的裁定是:寒冰陷阱把随从移回手牌,随从不死(因为它离开了 PLAY 区,死亡结算只扫描 PLAY 区)。

暴露的架构要求process_deaths 只扫描 Zone.PLAY。一个”死亡待定”的实体如果在结算前离开了战场,它就不会死。这是一个有意的设计(《暗影步》救随从的原理),但必须显式实现。

def process_deaths(self):
dying = []
for pid in (0, 1):
for e in self.zones.get(pid, Zone.PLAY): # ★ 只扫 PLAY
if e.current_health <= 0 or e.has(Tag.TO_BE_DESTROYED):
dying.append(e)

而且离开战场时必须清除 TO_BE_DESTROYED 标记,否则随从回到战场时会立刻死:

def move(self, e, to_zone, ...):
if e[Tag.ZONE] == Zone.PLAY and to_zone != Zone.PLAY:
e[Tag.TO_BE_DESTROYED] = 0
e[Tag.DAMAGE] = 0 # ★ 离开战场清除伤害

案例 5:光环的”延迟一帧”问题

这是所有事件驱动引擎都会遇到的经典 bug

场面:你打出《暴风城勇士》(其他随从 +1/+1),同时你有一个《飞刀杂耍者》(每当你召唤一个随从,对随机敌人造成 1 点伤害)。

问题:飞刀杂耍者的伤害是在勇士的光环生效前还是后计算的?(假设有卡让飞刀的伤害等于它的攻击力)

这个问题的一般形式emit(SUMMON)aura_bus.recompute() 谁先?

炉石的答案:光环先。所以:

def summon(game, card_id, controller, position=None):
e = game.create_entity(...)
ok = game.zones.move(e, Zone.PLAY, position)
if not ok: return None
game.event_bus.register_card(e)
game.aura_bus.mark_dirty()
game.aura_bus.recompute() # ★ 在 emit 之前
game.emit(Event.SUMMON, entity=e)
return e

为什么? 因为如果光环后重算,触发器看到的是”过时的状态”,会导致玩家看到的画面和实际计算不一致。

但这带来一个新问题:光环重算本身会改变 tag,可能触发”tag 变化时”的效果,形成循环。

炉石的解法(推断):光环重算不发出触发事件。光环导致的 tag 变化只同步给客户端,不进入事件总线。

def _apply_diff(self, old, new):
for eid in touched:
...
e.tags[tag] += d
self.game.record_tag_change(e, tag, ...) # 只同步,不 emit

这是一个重要的架构决策:把”状态变化”和”游戏事件”分开。前者是给客户端看的,后者是给规则用的。如果混在一起,任何光环都可能引发无限循环。


案例 6:变形与附魔的丢失

场面:你有一个被《王者祝福》加成的 4/2 → 8/6 随从。对手用《变形术》把它变成 1/1 绵羊。

问题:变回来会怎样?(假设有卡能变回)

答案:变不回来。变形是单向的、破坏性的——原卡的所有信息(除了 ENTITY_ID 和位置)都被抹除。

为什么这么设计? 因为如果保留原始信息,就要维护一个”变形栈”,而且要处理”变形的变形的变形”。炉石选择了最简单的语义:变形 = 用新定义重置实体

推论

  1. 变形不触发亡语(实体没死)
  2. 变形清除所有附魔(重置)
  3. 变形保留已受伤害吗?不保留——绵羊是满血的 1/1
  4. 变形保留 EXHAUSTED 吗?保留——变形不能解除召唤失调
  5. 变形保留 FROZEN 吗?保留

第 3 和第 4/5 的不一致(伤害清除但状态保留)是一个纯粹的实现细节泄漏到规则层的例子。理论上应该有一个明确的”变形保留什么”清单,实际上炉石的清单看起来是”位置相关 + 回合相关的保留,卡牌属性相关的重置”。

TRANSFORM_PRESERVED = {
Tag.ENTITY_ID, Tag.ZONE, Tag.ZONE_POSITION,
Tag.CONTROLLER, Tag.OWNER,
Tag.EXHAUSTED, Tag.NUM_ATTACKS, Tag.FROZEN,
Tag.NUM_TURNS_IN_PLAY,
}

给你的建议:在自己的引擎里,把这个清单显式写成常量并写单元测试。因为它会被无数张卡依赖,而且很容易在重构中悄悄改变。


案例 7:随机目标的分布与”看起来不随机”

场面:《飞刀杂耍者》对随机敌人造成 1 点伤害。对方有英雄 + 3 个随从。

问题:英雄被选中的概率是多少?

答案:1/4。飞刀的目标池是”所有敌方角色”,英雄和随从等概率。

但玩家的直觉是:”应该更可能打随从吧?” 或 “应该打脸吧?”

这引出一个重要的产品问题:均匀随机在感知上不是随机的。玩家会记住”连续三次都打脸”这种事件,而这在均匀随机下完全正常(概率 1/64)。

炉石的处理(推断):没有处理,就是均匀随机。但它做了一件事:RNG 的消费顺序是确定的且服务器权威的,所以不会有”客户端和服务器算出不同结果”的问题,也不会有作弊空间。

工程上的要点

class GameRNG:
"""所有游戏随机必须走这里。"""
def __init__(self, seed: int):
self._rng = random.Random(seed)
self._call_log: list[tuple[str, Any]] = [] # 用于回放

def choice(self, seq):
if not seq: raise ValueError("empty sequence")
i = self._rng.randrange(len(seq))
self._call_log.append(("choice", len(seq), i))
return seq[i]

def sample(self, seq, k):
k = min(k, len(seq))
idx = self._rng.sample(range(len(seq)), k)
self._call_log.append(("sample", len(seq), tuple(idx)))
return [seq[i] for i in idx]

def randint(self, a, b):
v = self._rng.randint(a, b)
self._call_log.append(("randint", (a, b), v))
return v

def shuffle(self, lst):
perm = list(range(len(lst)))
self._rng.shuffle(perm)
self._call_log.append(("shuffle", len(lst), tuple(perm)))
lst[:] = [lst[i] for i in perm]

关键点

  1. 记录调用日志,不只是 seed。因为如果代码逻辑变了(比如某个卡的实现改了消费顺序),单靠 seed 无法复现旧对局。
  2. 传入的序列长度也要记录,这样能在回放时校验状态一致性。
  3. 绝对不能有第二个随机源。任何 random.xxx() 的直接调用都是 bug。

如何强制这一点:在测试环境里 monkey-patch 全局 random,让任何直接调用都抛异常。

# conftest.py
import random
def _forbidden(*a, **k):
raise RuntimeError("Direct use of global random is forbidden. Use game.rng.")
for name in ("random", "randint", "choice", "shuffle", "sample"):
setattr(random, name, _forbidden)

案例 8:满手牌与烧牌

场面:你手牌 10 张,牌库里还有牌。你抽了一张。

问题:会发生什么?

答案:牌被”烧掉”——从牌库移除,直接进墓地,玩家看到一个燃烧动画。

微妙之处

  1. 烧掉的牌算作”抽到了”吗?——。所以《过度抽牌》类的”抽 N 张”会真的抽 N 张,只是烧掉。
  2. 烧掉的牌会触发”抽到时”效果吗?——不会。因为它没进手牌。
  3. 但《抽到时施放》的法术呢?——会施放(实测行为)。这是个特例。
  4. 烧牌触发”你弃掉一张牌”吗?——不会。烧牌和弃牌是不同事件。
def draw(self, pid, n=1):
for _ in range(n):
deck = self.zones.get(pid, Zone.DECK)
if not deck:
self._fatigue(pid); continue
card = deck.pop()
# ★ 先触发"抽到时施放",再判断手牌是否满
if card.has(Tag.CASTS_WHEN_DRAWN):
self.cast_from_draw(card, pid)
continue
hand = self.zones.get(pid, Zone.HAND)
if len(hand) >= 10:
self.zones.move(card, Zone.GRAVEYARD)
self.emit(Event.CARD_BURNED, entity=card, player=pid)
else:
self.zones.move(card, Zone.HAND)
card[Tag.DRAWN_ON_TURN] = self.turn
self.emit(Event.DRAW, entity=card, player=pid)

暴露的架构要求draw 不是一个原子操作,它是一个有多个出口的流程(疲劳 / 抽到时施放 / 烧牌 / 正常入手)。每个出口发不同的事件。如果把它写成”从牌库取一张放到手牌”,上面四条规则一条都实现不了。


案例 9:无限循环与强制判和

场面:某些卡组合能产生无限触发链。历史上真实存在过:

  • 《米尔豪斯》+《紫罗兰教师》+ 0 费法术
  • 《螺丝钉》+《蓝腮战士》类互相召唤
  • 2021 年《任务法》的某些组合

炉石的处理:检测到过深的触发链时,强制结束对局判和

MAX_TRIGGER_DEPTH = 128
MAX_DEATH_ROUNDS = 64
MAX_TURN_ACTIONS = 4096 # 单回合动作数上限

def resolve(self):
self._depth += 1
if self._depth > MAX_TRIGGER_DEPTH:
self.game.force_draw("INFINITE_LOOP_DETECTED")
self.queue.clear()
self._depth -= 1
return
...

为什么不能证明不存在环?

因为触发关系是运行时构造的:卡 A 的效果生成卡 B,卡 B 的触发调用卡 C……这个图在卡牌定义层面是无法静态分析的(除非你限制 DSL 的表达力)。而且随着卡池增长,新卡随时可能与老卡形成环。

所以:任何长期运营的卡牌游戏引擎都必须有循环保护。 这不是可选项。

另一个必须有的保护回合时间限制。炉石的回合是 75 秒(含加时),超时强制结束回合。这不只是为了游戏体验,也是为了防止”玩家用无限循环卡死服务器”。


案例 10:客户端预测与”回滚”

场面:你点击一张牌打出。客户端立刻播放动画,同时把请求发给服务器。

问题:如果服务器拒绝了这个动作怎么办?

炉石的处理:客户端不做完整预测。它只做”乐观 UI”(卡牌跟随鼠标、高亮合法目标),但实际的状态变化完全等服务器的 PowerHistory

这就是为什么炉石有明显的操作延迟(约 100-300ms)——它牺牲了响应速度换取了零回滚

对比:如果做完整客户端预测,就必须实现回滚,而回滚在有随机性和隐藏信息的情况下几乎不可能做对:

  • 客户端不知道对手手牌 → 无法预测奥秘触发
  • 客户端不知道 RNG 序列 → 无法预测随机结果
  • 客户端不知道对手牌库 → 无法预测《对决》结果

PowerHistory 的形态推断,基于社区抓包分析):

@dataclass
class PowerBlock:
"""一个原子的状态变化块,对应客户端的一段动画。"""
block_type: str # PLAY / TRIGGER / ATTACK / POWER / DEATHS
source_entity: int
target_entity: int | None
sub_blocks: list["PowerBlock"]
changes: list["PowerChange"]

@dataclass
class PowerChange:
kind: str # TAG_CHANGE / FULL_ENTITY / SHOW_ENTITY / HIDE_ENTITY
entity: int
tag: int | None = None
value: Any = None

一次出牌产生的 PowerHistory 大致是:

BLOCK(PLAY, source=火球术, target=敌方随从)
├─ TAG_CHANGE(玩家, MANA_AVAILABLE, 8→4)
├─ TAG_CHANGE(火球术, ZONE, HAND→PLAY)
├─ TAG_CHANGE(火球术, ZONE_POSITION, 3→0)
├─ TAG_CHANGE(手牌其他卡, ZONE_POSITION, ...) ← 位置重排
├─ BLOCK(POWER, source=火球术)
│ ├─ TAG_CHANGE(敌方随从, DAMAGE, 0→6)
│ └─ META(DAMAGE, entity=敌方随从, amount=6) ← 用于伤害数字动画
├─ BLOCK(DEATHS)
│ ├─ TAG_CHANGE(敌方随从, ZONE, PLAY→GRAVEYARD)
│ └─ TAG_CHANGE(敌方随从, ZONE_POSITION, 2→0)
└─ TAG_CHANGE(火球术, ZONE, PLAY→GRAVEYARD)

嵌套的 BLOCK 结构是客户端播放动画的依据:每个 BLOCK 对应一段动画,嵌套关系决定播放顺序和视觉层级。

这个设计的价值:服务器只需要产生一个纯数据的、树形的变化日志,客户端负责把它演出来。两边完全解耦。你可以换整个客户端(比如做一个纯文本客户端)而不动服务器。

在你自己的引擎里怎么做

class Game:
def record_tag_change(self, e, tag, old, new):
self.power_history.append(PowerChange(
kind="TAG_CHANGE", entity=e.id, tag=int(tag), value=new))

@contextmanager
def power_block(self, block_type, source, target=None):
blk = PowerBlock(block_type, source.id if source else 0,
target.id if target else None, [], [])
parent = self._current_block
(parent.sub_blocks if parent else self.power_history).append(blk)
self._current_block = blk
try:
yield blk
finally:
self._current_block = parent

# 用法
def play_card(self, card, target=None, ...):
with self.power_block("PLAY", card, target):
...

一个 context manager 就搞定了整个网络同步层。 这是我认为最优雅的部分:因为所有状态变化都走 __setitem__,你只要在那里记录,就自动得到了完整的、正确顺序的变化日志。


案例小结:反复出现的四个模式

回看这十个案例,有四个模式反复出现:

模式一:快照(Snapshot)

出现在:亡语位置、攻击力、目标列表、终曲判定、死亡时的相邻关系、发现的卡池。

规则任何延迟执行的效果,必须在注册时捕获它需要的全部上下文。

模式二:批量原子操作(Batch Atomicity)

出现在:死亡结算、AOE 伤害、光环重算、同时移入墓地。

规则“同时发生”的事必须真的同时发生,中间不能被观察到中间状态。

模式三:唯一入口(Single Choke Point)

出现在:zones.move(区域变化)、entity.__setitem__(tag 变化)、game.damage(伤害)、game.rng(随机)。

规则每一类状态变化只有一个函数能做,所有的横切关注点(日志、同步、不变式检查)都挂在那里。

模式四:状态与事件分离(State vs Event)

出现在:光环重算不 emit、record_tag_change vs emit

规则“给客户端看的状态变化”和”给规则用的游戏事件”是两回事,混在一起必然产生循环。


第六部分 工程实践


第 28 章 确定性随机与回放

28.1 三个必须满足的性质

一个卡牌游戏的随机系统必须同时满足:

  1. 不可预测:玩家(包括作弊客户端)不能提前知道结果
  2. 可复现:服务器能从日志完整重放一局,用于 bug 排查和回放功能
  3. 不可污染:AI 的模拟不能影响真实对局的随机序列

28.2 性质 1:不可预测

不能做的事

  • ❌ 用固定 seed(玩家能算出来)
  • ❌ 用时间戳作 seed(可预测)
  • ❌ 在客户端生成随机数

正确做法:服务器用密码学安全的随机源生成 seed,且 seed 不下发给客户端

import secrets

class Game:
def __init__(self, seed: int | None = None):
self.seed = seed if seed is not None else secrets.randbits(64)
self.rng = GameRNG(self.seed)

一个真实的历史问题:早期炉石客户端能通过内存读取预知下一张抽的牌(因为牌库顺序在开局就确定并下发了)。现代实现应该惰性决定牌库顺序——只在真正抽牌时才决定抽到哪张。

class LazyDeck:
"""惰性洗牌:只在抽牌时决定,减少信息泄露面。"""
def __init__(self, cards: list[Entity], rng: GameRNG):
self._pool = list(cards) # 未决定的牌
self._order = [] # 已决定的顺序(牌库顶在末尾)
self.rng = rng

def draw(self) -> Entity | None:
if self._order:
return self._order.pop()
if not self._pool:
return None
i = self.rng.randrange(len(self._pool))
return self._pool.pop(i)

def peek_bottom(self, n) -> list[Entity]:
"""打捞需要看牌库底 —— 此时才决定这三张"""
while len(self._order) < n and self._pool:
i = self.rng.randrange(len(self._pool))
self._order.insert(0, self._pool.pop(i))
return self._order[:n]

惰性洗牌的额外好处:《打捞》《检索》等效果只需要”决定”它们查看的那几张,其余保持未定。这减少了内存里”已知的完整牌序”的存在时间。

注意:惰性洗牌在数学上等价于提前洗牌(都是均匀排列),所以不影响概率。

28.3 性质 2:可复现

只记 seed 不够。因为如果引擎代码变了(新版本修了一个 bug,改变了 RNG 消费顺序),旧日志就无法重放。

正确做法:记录 RNG 调用日志

@dataclass
class RngCall:
op: str # "choice" / "randint" / "shuffle" / "sample"
domain: Any # 输入的规模(用于校验)
result: Any

class ReplayRNG(GameRNG):
"""回放模式:不生成随机数,从日志读取。"""
def __init__(self, log: list[RngCall]):
self.log = log
self.idx = 0

def choice(self, seq):
call = self.log[self.idx]; self.idx += 1
assert call.op == "choice", f"RNG desync at {self.idx}: expected {call.op}"
assert call.domain == len(seq), \
f"RNG desync: log says pool size {call.domain}, got {len(seq)}"
return seq[call.result]

assert call.domain == len(seq) 是最有价值的一行。它会在回放时立刻检测出”引擎行为变了”——因为如果某张卡的实现改了,目标池的大小可能不同,assert 会失败并告诉你确切位置。

我在实践中的建议:把这个 assert 做成一个回归测试。存一批历史对局日志,每次改引擎后重放,任何 desync 都是行为变更的信号。这比写单元测试覆盖率高得多——一局真实对局能覆盖上百个交互

28.4 性质 3:不可污染

AI 模拟时会大量调用引擎。如果它用同一个 RNG,真实对局的随机序列就被打乱了。

解法:模拟时用独立的 RNG,且状态是深拷贝的。

def clone_for_simulation(game: Game, sim_seed: int) -> Game:
g = copy.deepcopy(game)
g.rng = GameRNG(sim_seed) # ★ 独立的 RNG
g.is_simulation = True
g.power_history = [] # 不记录
g.sim_policy = DefaultSimPolicy()
return g

is_simulation 这个标志会改变几个行为:

  • Discover 不挂起,直接按策略选
  • 不产生 PowerHistory
  • 不做网络同步
  • 隐藏信息用采样代替(下一节)

28.5 深拷贝的性能问题

copy.deepcopy 对于一个有几百个实体的 GameState 大约需要 1-3 毫秒。如果 MCTS 要跑 10000 次模拟,光是拷贝就要 10-30 秒——太慢了。

优化方案(按效果排序)

方案 A:手写 clone

class Entity:
__slots__ = (...)
def clone(self, game):
e = Entity.__new__(Entity)
e.id = self.id
e.game = game
e.tags = self.tags.copy() # dict.copy() 比 deepcopy 快 20x
e._enchantments = [x.clone(game) for x in self._enchantments]
return e

实测能快 10-20 倍。

方案 B:结构共享(Persistent Data Structure)

用不可变数据结构 + 写时复制。Python 里可以用 pyrsistent

from pyrsistent import pmap, pvector

@dataclass(frozen=True)
class GameState:
entities: "PMap[int, EntityState]"
zones: "PMap[tuple, PVector]"
turn: int
current: int

def with_tag(self, eid, tag, value) -> "GameState":
e = self.entities[eid]
return replace(self, entities=self.entities.set(eid, e.set(tag, value)))

拷贝变成 O(1),但每次修改有 O(log n) 的开销。对于 MCTS 这种”拷贝多、修改少”的场景是净胜。

方案 C:Undo Log(撤销日志)

不拷贝,而是记录所有变化,模拟完后撤销:

class UndoableGame(Game):
def __init__(self, ...):
self._undo_stack = []

def record_tag_change(self, e, tag, old, new):
self._undo_stack.append(("tag", e.id, tag, old))

def checkpoint(self) -> int:
return len(self._undo_stack)

def rollback(self, cp: int):
while len(self._undo_stack) > cp:
kind, *args = self._undo_stack.pop()
if kind == "tag":
eid, tag, old = args
self.entities[eid].tags[tag] = old
elif kind == "zone":
...

这是最快的方案(O(变化量) 而不是 O(状态大小)),但也最容易出 bug——你必须保证每一种状态变化都被记录,包括实体创建、区域移动、触发器注册。

注意:方案 C 恰好也是实现 2025 年《倒带》关键词所需要的。如果你的引擎有 undo log,倒带就是免费的。

我的建议:先做方案 A(简单、快 20 倍、够用),只有在真的需要跑百万次模拟时才上 B 或 C。


第 29 章 网络同步与隐藏信息

29.1 服务器权威模型

客户端                     服务器
│ │
│──── PlayCard(id, tgt) ──>│
│ │ 校验合法性
│ │ 执行 play_card()
│ │ 收集 PowerHistory
│<─── PowerHistory[] ──────│
│ 播放动画 │
│ │
│<─── PowerHistory[] ──────│ (对手的操作)

关键点:客户端从不计算规则。它只做两件事:发送意图、播放服务器返回的变化。

这个模型的代价:延迟。你点击到看到效果之间有一个 RTT。炉石通过”卡牌离手的动画”来掩盖这个延迟(动画时长 > RTT)。

这个模型的收益

  1. 零作弊面。客户端改内存最多能看到它本来就知道的东西
  2. 零回滚代码
  3. 回放免费(PowerHistory 就是回放数据)
  4. 观战免费(同一份 PowerHistory 发给观战者)

29.2 隐藏信息的过滤

服务器有完整状态,但不能全发给客户端。过滤规则:

def filter_for_player(entity: Entity, viewer: int) -> dict:
"""返回该玩家能看到的 tag 子集"""
zone = entity[Tag.ZONE]
owner = entity[Tag.CONTROLLER]

# 完全可见
if zone in (Zone.PLAY, Zone.GRAVEYARD):
return entity.tags

# 自己的手牌:全可见
if zone == Zone.HAND and owner == viewer:
return entity.tags

# 对手的手牌:只知道存在和位置
if zone == Zone.HAND:
return {Tag.ENTITY_ID: entity.id, Tag.ZONE: zone,
Tag.ZONE_POSITION: entity[Tag.ZONE_POSITION],
Tag.CONTROLLER: owner}
# ★ 注意:不发 CARD_ID!

# 牌库:只知道数量
if zone == Zone.DECK:
return {Tag.ENTITY_ID: entity.id, Tag.ZONE: zone,
Tag.CONTROLLER: owner}

# 奥秘:对手只知道存在,除非已揭示
if zone == Zone.SECRET:
if owner == viewer or entity[Tag.REVEALED]:
return entity.tags
return {Tag.ENTITY_ID: entity.id, Tag.ZONE: zone,
Tag.CONTROLLER: owner, Tag.CLASS: entity[Tag.CLASS]}
# ★ 职业是可见的(客户端显示奥秘图标的颜色)

return {}

一个真实的信息泄露教训:早期炉石会把奥秘的 CARD_ID 发给对手(虽然 UI 不显示),第三方工具能读出来。修复后,CARD_ID 在揭示前不下发。

测试这个的方法:写一个测试,模拟一局游戏,检查发给玩家 A 的所有 PowerHistory 里不包含玩家 B 的手牌 CARD_ID。

def test_no_hand_leak():
g = make_game(seed=42)
play_random_game(g)
for msg in g.power_history_for(player=0):
for change in msg.changes:
e = g.entities[change.entity]
if (e[Tag.ZONE] == Zone.HAND and e[Tag.CONTROLLER] == 1
and change.tag == Tag.CARD_ID):
pytest.fail(f"Leaked opponent hand card: {change}")

29.3 带宽优化

一局炉石大约产生 2000-8000 个 PowerChange。每个 change 如果用 JSON 大约 60 字节,一局就是 120-480 KB。在移动网络上这是可接受的,但可以优化:

  1. 变长整数编码(protobuf varint):tag 值大多是小整数
  2. 实体 ID 相对编码:连续的 change 常常针对同一实体
  3. 批量位置更新ZONE_POSITION 的重排可以压成一条消息
  4. 省略可推导的变化:客户端能自己算出来的不发(但这会引入”客户端逻辑”,慎用)

炉石实际用的是 Protocol Buffers推断,社区抓包证实)。

29.4 断线重连

服务器必须能生成一个完整状态快照(而不是增量):

def full_state_snapshot(game, viewer: int) -> dict:
return {
"game_id": game.id,
"turn": game.turn,
"current_player": game.current,
"entities": [
{"id": e.id, "tags": filter_for_player(e, viewer)}
for e in game.all_entities()
if filter_for_player(e, viewer)
],
"phase": int(game.phase),
"pending_choice": game.pending_choice_for(viewer),
}

难点:如果断线时玩家正在做一个《发现》选择,重连后必须恢复那个选择框。所以 pending_choice 必须是可序列化的状态,而不是一个 Python 闭包。

这是我在 5.3 节推荐用 async 而不是异常的另一个理由——async 的挂起点也不好序列化。最健壮的做法是把”待决策”表达成显式的数据:

@dataclass
class PendingChoice:
kind: str # DISCOVER / ADAPT / CHOOSE_ONE / TARGET / MULLIGAN
player: int
options: list[str] # 卡牌 ID
continuation_id: str # 一个可以从状态重建的标识
deadline: float

然后 continuation 通过一个注册表查找,而不是闭包:

CONTINUATIONS = {}

def continuation(name):
def deco(f):
CONTINUATIONS[name] = f
return f
return deco

@continuation("DISCOVER_ADD_TO_HAND")
def _(game, ctx, chosen):
game.add_to_hand(ctx.controller, chosen)

这样整个”待决策”状态就是纯数据,可以序列化、可以存数据库、可以在服务器重启后恢复。


第 30 章 AI 与模拟器

30.1 为什么炉石 AI 很难

难点说明
不完全信息看不到对手手牌、牌库
高分支因子一个回合可能有 103~105 个合法动作序列
随机性同一个动作有多种结果
长期规划一局 10-20 回合,需要资源管理
卡牌语义6000 张卡,无法手写启发式

分支因子的估算:假设手里 7 张牌,场上 5 个随从,对面 5 个随从 + 英雄:

  • 出牌:7 张 × 平均 3 个目标 × 8 个位置 ≈ 168
  • 攻击:5 个攻击者 × 6 个目标 = 30
  • 英雄技能:1-3
  • 顺序组合:一个回合可能打 3-4 张牌,顺序有意义

粗算单回合的动作序列数在 10^4 到 10^6 之间。这比围棋的单步分支(~250)大得多,但深度浅得多。

30.2 动作空间的表示

@dataclass(frozen=True)
class GameAction:
kind: str # PLAY / ATTACK / HERO_POWER / END_TURN /
# TRADE / FORGE / PREPARE / USE_LOCATION / TITAN_ABILITY
card_id: int = 0 # entity id
target_id: int = 0
position: int = -1
choice: int = 0 # 抉择/发现的选项索引

def legal_actions(game, pid) -> list[GameAction]:
out = [GameAction("END_TURN")]
p = game.player(pid)

# 出牌
for c in game.zones.get(pid, Zone.HAND):
cost = game.compute_cost(c)
if cost > p[Tag.MANA_AVAILABLE]:
continue
if c[Tag.LOCKED_UNTIL_TURN] > game.turn: # Prepare 锁定
continue
targets = game.legal_targets_for(c) or [None]
positions = (range(len(game.zones.get(pid, Zone.PLAY)) + 1)
if c.is_minion else [-1])
choices = range(len(c.defn.choose_one)) if c.defn.choose_one else [0]
for t in targets:
for pos in positions:
for ch in choices:
out.append(GameAction("PLAY", c.id,
t.id if t else 0, pos, ch))
if c.has(Tag.TRADEABLE) and p[Tag.MANA_AVAILABLE] >= 1:
out.append(GameAction("TRADE", c.id))
if c.has(Tag.PREPARE) and p[Tag.MANA_AVAILABLE] >= 1:
for amt in range(1, p[Tag.MANA_AVAILABLE] + 1):
out.append(GameAction("PREPARE", c.id, choice=amt))

# 攻击
for a in game.zones.get(pid, Zone.PLAY) + [p.hero]:
if not game.can_attack(a): continue
for d in game.legal_attack_targets(a):
out.append(GameAction("ATTACK", a.id, d.id))

# 英雄技能
if game.can_use_hero_power(pid):
hp = p.hero_power
for t in (game.legal_targets_for(hp) or [None]):
out.append(GameAction("HERO_POWER", hp.id, t.id if t else 0))

return out

注意 PREPARE 让分支因子爆炸:一张可准备的牌,每个可能的法力值消耗量都是一个分支。实践中 AI 应该剪枝(只考虑”花光所有法力”和”花 1 点”两个选项)。

30.3 MCTS 与信息集

标准 MCTS 假设完全信息。对于炉石,要用 ISMCTS(Information Set MCTS)

def ismcts(root_state, pid, iterations=10000):
root = Node(None, None)
for _ in range(iterations):
# ★ 1. 确定化(Determinization):给隐藏信息采样一个具体值
state = determinize(root_state, pid)
node = root
path = [node]

# 2. Selection
while node.children and not state.is_terminal():
legal = legal_actions(state, state.current)
node = node.select_uct(legal)
state.apply(node.action)
path.append(node)

# 3. Expansion
if not state.is_terminal():
legal = legal_actions(state, state.current)
untried = [a for a in legal if a not in node.children]
if untried:
a = random.choice(untried)
state.apply(a)
node = node.add_child(a)
path.append(node)

# 4. Simulation(rollout)
result = rollout(state, pid, max_depth=30)

# 5. Backpropagation
for n in path:
n.visits += 1
n.wins += result

return max(root.children.values(), key=lambda n: n.visits).action


def determinize(state, pid):
"""把对手的手牌和双方牌库替换成一个符合已知约束的具体采样。"""
s = state.clone()
opp = 1 - pid
# 已知约束:对手职业、已打出的牌、牌库剩余数量、可能的卡组原型
known = s.observed_cards(opp)
archetype = infer_archetype(known) # 元游戏知识
pool = archetype.remaining_cards(known)
for card in s.zones.get(opp, Zone.HAND):
card.set_card_id(sample_from(pool))
for card in s.zones.get(opp, Zone.DECK):
card.set_card_id(sample_from(pool))
return s

infer_archetype 是炉石 AI 最有价值的部分。因为构筑卡组不是随机的——如果对手打了《寒冰箭》和《奥术智慧》,他大概率是法师控制或者奥秘法,你可以用元游戏数据(HSReplay 的公开卡组统计)来采样一个合理的牌库。

这比”均匀从所有法师卡里采样”强得多。

30.4 状态评估函数

Rollout 到终局太慢(一局 20 回合 × 每回合 10 个动作 = 200 步)。实践中用评估函数 + 浅层搜索

def evaluate(state, pid) -> float:
me, opp = state.player(pid), state.player(1 - pid)

# 1. 生命值(非线性:低血更危险)
def hp_score(p):
h = p.hero.current_health + p.hero[Tag.ARMOR]
return math.log1p(max(0, h)) * 3
score = hp_score(me) - hp_score(opp)

# 2. 场面
def board_score(pid_):
s = 0
for m in state.zones.get(pid_, Zone.PLAY):
v = m[Tag.ATK] * 1.0 + m.current_health * 1.0
if m.has(Tag.TAUNT): v += m.current_health * 0.5
if m.has(Tag.DIVINE_SHIELD): v += m[Tag.ATK] * 0.5 + 1
if m.has(Tag.POISONOUS): v += 3
if m.has(Tag.LIFESTEAL): v += m[Tag.ATK] * 0.5
if m.has(Tag.WINDFURY): v += m[Tag.ATK] * 0.8
if m.defn.deathrattle: v += 1.5
if m.defn.auras: v += 2
if m.has(Tag.STEALTH): v += 1
if m[Tag.FROZEN]: v -= m[Tag.ATK] * 0.5
s += v
return s
score += (board_score(pid) - board_score(1 - pid)) * 1.5

# 3. 卡差
score += (len(state.zones.get(pid, Zone.HAND))
- len(state.zones.get(1 - pid, Zone.HAND))) * 2.0

# 4. 资源
score += (me[Tag.MANA_CRYSTALS] - opp[Tag.MANA_CRYSTALS]) * 0.5
score -= me[Tag.OVERLOAD_OWED] * 0.8

# 5. 疲劳风险
score -= max(0, 5 - len(state.zones.get(pid, Zone.DECK))) * 1.5

# 6. 致命检查(压倒一切)
if opp.hero.current_health + opp[Tag.ARMOR] <= 0: return 1e9
if me.hero.current_health + me[Tag.ARMOR] <= 0: return -1e9

return score

这个函数的每一项都可以从卡牌 DSL 自动导出——这是数据驱动的又一个红利。m.defn.deathrattle is not None 加 1.5 分,不需要为每张卡手写规则。

更进一步,你可以从 DSL 静态估算一张卡的”价值”

def estimate_card_value(defn: CardDef) -> float:
v = 0.0
for action in walk_actions(defn.all_actions()):
if isinstance(action, Damage):
v += action.static_amount() * 1.0
elif isinstance(action, Draw):
v += action.static_amount() * 2.0
elif isinstance(action, Summon):
v += estimate_card_value(CARD_DB[action.card_id]) * action.count
elif isinstance(action, Destroy):
v += 4.0
elif isinstance(action, Heal):
v += action.static_amount() * 0.6
v += defn.attack * 1.0 + defn.health * 1.0
for kw in defn.keywords:
v += KEYWORD_VALUE.get(kw, 0)
return v - defn.cost * 2.0 # 费用是负项

这个函数能自动给 6000 张卡打分,而且新卡上线时零改动。它甚至能用来做自动平衡性检查:”这张卡的估值远高于同费用的中位数,可能超模”。

30.5 分层搜索:先解场面再规划

一个实用的工程技巧:把”这个回合怎么打”和”整局怎么打”分开

def decide_turn(game, pid):
# 第一层:枚举本回合的动作序列,用评估函数打分
best_seq, best_score = None, -inf
for seq in enumerate_turn_sequences(game, pid, beam_width=200):
s = game.clone()
for a in seq:
s.apply(a)
score = evaluate(s, pid)
# 第二层:粗略模拟对手的回应
score -= estimate_opponent_best_response(s, 1 - pid)
if score > best_score:
best_seq, best_score = seq, score
return best_seq


def enumerate_turn_sequences(game, pid, beam_width=200):
"""束搜索:每步保留最好的 N 个部分序列"""
beam = [([], game.clone(), 0.0)]
for _ in range(12): # 一回合最多 12 个动作
nxt = []
for seq, state, _ in beam:
for a in legal_actions(state, pid):
if a.kind == "END_TURN":
nxt.append((seq, state, evaluate(state, pid)))
continue
s2 = state.clone()
s2.apply(a)
nxt.append((seq + [a], s2, evaluate(s2, pid)))
nxt.sort(key=lambda x: -x[2])
beam = nxt[:beam_width]
if all(s[0] and s[0][-1].kind == "END_TURN" for s in beam):
break
return [s[0] for s in beam]

束搜索(beam search)在实践中比 MCTS 更实用,因为:

  1. 炉石的回合内决策没有随机性影响顺序(大部分情况)
  2. 评估函数已经足够好
  3. 可预测的时间开销(beam_width × depth 次评估)

这也是社区 AI(如 Silverfish、SabberStone 的 AI)实际采用的方案。


第 31 章 性能

31.1 热点在哪里

用 cProfile 跑 10000 局随机对局,热点分布大致是(我的迷你引擎实测):

  32%  aura_bus.recompute       ← 光环重算
18% selector.eval ← 筛选器求值
14% copy / clone ← 状态拷贝(AI 场景)
11% event_bus.emit ← 事件匹配
9% entity.__setitem__ ← tag 写入 + 记录
7% process_deaths
9% 其他

31.2 光环重算优化

优化 1:脏标记分级(29.3 讲过)

优化 2:缓存光环源列表

class AuraBus:
def __init__(self, game):
self._sources_cache = None
self._sources_dirty = True

def _collect_sources(self):
if not self._sources_dirty:
return self._sources_cache
# 只在实体进出场时重新收集
...
self._sources_dirty = False
return self._sources_cache

大部分回合里没有实体进出场(只是攻击和数值变化),所以源列表可以复用。

优化 3:早退

如果场上没有任何光环源(很常见),直接跳过:

def recompute(self):
srcs = self._collect_sources()
if not srcs and not self._deltas:
self._dirty = False
return # ★ 零成本

31.3 Selector 求值优化

优化 1:避免重复构建列表

# ❌ 每次都建新 list
class Friendly(Selector):
def eval(self, game, ctx):
return [e for e in self.inner.eval(game, ctx) if ...]

# ✓ 用生成器 + 只在最外层物化
class Friendly(Selector):
def iter_eval(self, game, ctx):
me = ctx.controller
for e in self.inner.iter_eval(game, ctx):
if e[Tag.CONTROLLER] == me:
yield e
def eval(self, game, ctx):
return list(self.iter_eval(game, ctx))

优化 2:常量折叠

Friendly(AllMinions()) 每次都要遍历全场再过滤。可以特化:

class FriendlyMinions(Selector):
"""特化版本,直接查一个区域"""
def eval(self, game, ctx):
return [e for e in game.zones.get(ctx.controller, Zone.PLAY)
if e[Tag.CARDTYPE] == CardType.MINION]

在 Selector 构建时做一次”优化 pass”,把常见组合替换成特化实现。

31.4 事件匹配优化

emit 要遍历所有订阅者。优化:

按事件类型分桶(已做)+ 按区域二级分桶

self._subs: dict[Event, dict[Zone, list[tuple[Entity, TriggerDef]]]]

def emit(self, ctx):
buckets = self._subs.get(ctx.kind)
if not buckets: return
matched = []
for zone, lst in buckets.items():
for owner, td in lst:
if owner[Tag.ZONE] != zone: continue # 已经预筛
...

更激进:预编译 Condition

把 Condition 树编译成一个 Python 函数(用 eval 或者 ast):

def compile_condition(cond) -> Callable:
src = cond.to_python_expr() # "e.tags.get(5,0) > 0 and ctx.amount > 0"
return eval(f"lambda game, e, ctx: ({src})")

实测能快 3-5 倍,因为省掉了对象方法调用的开销。但代价是可读性和调试难度——只在确实是瓶颈时才做

31.5 一个反直觉的结论

我在优化迷你引擎时发现:entity[Tag.X] 这个看起来无害的操作是最大的隐性开销,因为它在整个引擎里被调用几十万次。

# 原始版本
def __getitem__(self, t):
return self.tags.get(t, 0) # dict.get + 默认值

# 优化版本:用 defaultdict 消除 get 的分支
self.tags = defaultdict(int)
def __getitem__(self, t):
return self.tags[t]

实测快 15%。但更好的做法是在热点路径上直接访问 e.tags,绕过 __getitem__

# 在 can_attack 这种每帧调用几百次的函数里
def can_attack(e) -> bool:
t = e.tags # 局部变量绑定
if t.get(Tag.FROZEN): return False
if t.get(Tag.ATK, 0) <= 0: return False
...

但不要过早优化。我的迷你引擎在不优化的情况下能跑约 400 局/秒(单核 Python),对于开发和测试完全够用。真正需要优化是当你要跑百万级模拟做 AI 训练时。


第 32 章 测试策略

32.1 为什么单元测试不够

10^7 对卡牌交互,你不可能写完。所以测试策略必须分层:

层一:规则不变式(Property-based)

用 Hypothesis 生成随机对局,检查不变式永远成立:

from hypothesis import given, strategies as st

@given(seed=st.integers(0, 2**32))
def test_invariants_hold(seed):
g = make_random_game(seed)
while not g.is_over and g.turn < 60:
actions = legal_actions(g, g.current)
g.apply(random.choice(actions))
check_invariants(g)

def check_invariants(g):
for pid in (0, 1):
board = g.zones.get(pid, Zone.PLAY)
# 战场不超过 7
assert len(board) <= 7
# 位置连续且从 1 开始
assert [e[Tag.ZONE_POSITION] for e in board] == list(range(1, len(board)+1))
# 手牌不超过 10
assert len(g.zones.get(pid, Zone.HAND)) <= 10
# 没有生命值 <= 0 的随从活在场上
for m in board:
assert m.current_health > 0, f"{m} should be dead"
# 法力值非负且不超过水晶数
p = g.player(pid)
assert 0 <= p[Tag.MANA_AVAILABLE] <= p[Tag.MANA_CRYSTALS]
# 每个实体只在一个区域
seen = set()
for pid in (0, 1):
for z in Zone:
for e in g.zones.get(pid, z):
assert e.id not in seen, f"{e.id} in two zones"
seen.add(e.id)
assert e[Tag.ZONE] == z

这类测试的价值极高:一个不变式能覆盖成千上万种情况。我在写迷你引擎时,ZONE_POSITION 连续性这一条就抓出了三个 bug。

层二:交互矩阵(Combinatorial)

自动枚举关键词的两两组合:

KEYWORD_TEST_CARDS = {
"TAUNT": "TEST_TAUNT", "DIVINE_SHIELD": "TEST_DS",
"POISONOUS": "TEST_POISON", "LIFESTEAL": "TEST_LS",
"STEALTH": "TEST_STEALTH", "WINDFURY": "TEST_WF",
"RUSH": "TEST_RUSH", "REBORN": "TEST_REBORN",
}

@pytest.mark.parametrize("kw_a,kw_b",
itertools.combinations(KEYWORD_TEST_CARDS, 2))
def test_keyword_pair_no_crash(kw_a, kw_b):
g = make_test_game()
a = g.summon_test(KEYWORD_TEST_CARDS[kw_a], player=0)
b = g.summon_test(KEYWORD_TEST_CARDS[kw_b], player=1)
g.attack(a, b)
check_invariants(g)
g.process_deaths()
check_invariants(g)

\binom{60}{2} = 1770 个测试,跑一遍不到一分钟。它不检查”结果对不对”,只检查”不崩溃且不变式成立”——但这已经能抓住绝大多数集成 bug。

层三:黄金对局(Golden Replay)

存一批真实对局的 RNG 日志 + 动作序列 + 期望的最终状态哈希:

GOLDEN_GAMES = load_golden_replays("tests/golden/*.json")

@pytest.mark.parametrize("replay", GOLDEN_GAMES, ids=lambda r: r.name)
def test_golden_replay(replay):
g = Game(rng=ReplayRNG(replay.rng_log))
g.load_decks(replay.decks)
g.start()
for action in replay.actions:
g.apply(action)
assert g.state_hash() == replay.expected_hash, \
f"State diverged. Diff:\n{diff_states(g, replay.expected_state)}"

这是最有价值的回归测试:任何行为变更都会被立刻发现。每次修 bug 时,把那局对局存成 golden replay。

层四:定向单元测试

针对每个已知的裁定写一个测试。这是最贵的,但对核心机制必须有:

def test_two_ghouls_dying_together():
"""案例 1:两个不稳定的食尸鬼同时死亡"""
g = make_test_game()
a = g.summon_test("FP1_024", player=0) # 不稳定的食尸鬼
b = g.summon_test("FP1_024", player=0)
c = g.summon_test("TEST_3_3", player=1)
g.set_damage(a, 2); g.set_damage(b, 2) # 都剩 1 血

g.damage_all_minions(2)
g.process_deaths()

assert a[Tag.ZONE] == Zone.GRAVEYARD
assert b[Tag.ZONE] == Zone.GRAVEYARD
# 两个亡语都触发了:c 受到 2 点额外伤害(3/3 → 受 2+1+1 = 4 → 死)
assert c[Tag.ZONE] == Zone.GRAVEYARD


def test_silence_windfury_warrior_kills_damaged_minion():
"""案例 2:沉默光环源导致随从死亡"""
g = make_test_game()
warrior = g.summon_test("EX1_055", player=0) # 暴风城勇士
priest = g.summon_test("EX1_019", player=0) # 破碎残阳祭司 3/2
assert priest[Tag.HEALTH] == 3 # 2 + 1(光环)
g.set_damage(priest, 2)
assert priest.current_health == 1

g.silence(warrior)
g.process_deaths()
assert priest[Tag.ZONE] == Zone.GRAVEYARD # 3-1=2 上限,受 2 伤 → 死


def test_divine_shield_blocks_poison():
"""案例:圣盾挡住剧毒"""
g = make_test_game()
poison = g.summon_test("TEST_POISON_1_1", player=0)
shield = g.summon_test("TEST_DS_5_5", player=1)
g.attack(poison, shield)
g.process_deaths()
assert shield[Tag.ZONE] == Zone.PLAY # 活着
assert not shield.has(Tag.DIVINE_SHIELD) # 但圣盾破了

32.2 我推荐的投入比例

测试数开发投入Bug 捕获率
不变式~15 条1 天40%
交互矩阵~2000 自动生成0.5 天25%
黄金对局~100 局持续积累25%
定向单测~500持续10%

不变式的投入产出比是最高的。如果你只有一天时间做测试,全部投在不变式上。


第七部分 可借鉴清单与完整源码


第 33 章 二十条可直接带走的设计经验

按重要性排序。前五条是我认为最有价值的。

★★★ 1. 一切皆实体,类型是标签而非类

# ❌
class Minion(Card): ...
class Spell(Card): ...

# ✓
e[Tag.CARDTYPE] == CardType.MINION

收益:变形、英雄牌、地标(新卡牌类型)、”随从变成武器”这类需求都不需要改架构。炉石在第 8 年加了一个全新卡牌类型(地标)而没有重写引擎,就是靠这个。

代价:类型检查从编译期移到运行时,IDE 补全变差。用 TypedDict 或运行时断言弥补。

★★★ 2. 存”最大生命值 + 已受伤害”,不存”当前生命值”

current_health = HEALTH - DAMAGE

收益:buff/沉默/光环的加减法幂等,不需要”扣回多少”的记账。

这一条几乎是所有成熟卡牌引擎的共识。 如果你现在的引擎存的是当前生命值,趁早改。

★★★ 3. 所有状态变化走唯一入口

四个入口:

entity.__setitem__   # tag 变化
zones.move # 区域变化
game.damage # 伤害
game.rng # 随机

收益:网络同步、日志、回放、不变式检查、undo log 全部只需要在四个地方挂钩子。

验证方法:全局搜索 .tags[ 的直接赋值,每一处都应该是有意的性能优化(且有注释说明为什么绕过)。

★★★ 4. 延迟执行的效果必须快照它的上下文

死亡快照、攻击力快照、目标列表快照、终曲快照……

# ❌ 执行时读取
def deathrattle(game, entity):
pos = entity[Tag.ZONE_POSITION] # 已经是 0 了!

# ✓ 注册时快照
snap = DeathSnapshot(position=e[Tag.ZONE_POSITION], ...)

这条规律适用于任何事件驱动系统,不只是卡牌游戏。

★★★ 5. 用队列而不是栈,把所有决策前置

收益:结算是原子的、可同步跑完的纯函数。这让 AI 能以每秒数万次的速度调用引擎,让服务器不需要维护”等待玩家响应”的挂起状态。

代价:失去了 MTG 式的实时互动深度。用奥秘、亡语等”预先承诺”机制补偿。

如果你在设计一个新游戏,认真考虑这个权衡。 大多数情况下队列是对的。


★★ 6. 死亡是两阶段的:标记 + 批量结算

damage()  →  只标记 TO_BE_DESTROYED
process_deaths() → 批量收集、批量入墓、按序触发亡语

调用点:每个玩家动作之后、每次触发队列清空之后、每次光环重算之后。

★★ 7. 光环全量重算,不做增量

而且:光环导致的 tag 变化不发出游戏事件,只做客户端同步。否则必然循环。

★★ 8. 合并/附加效果必须通过附魔实体实现

磁力、星舰、准备折扣、任何”给随从加东西”的效果。

收益:沉默/移除时一次性回滚,不需要每个效果记住自己改了什么。

★★ 9. 事件上下文宁可多带信息

amounttarget_health_beforefatalpaid_costsnapshot……

一次多存一个字段,省了后面三个关键词的改动(超杀/荣誉击杀/狂乱共用 target_health_before)。

★★ 10. 用统一的 PlayerStats 承载所有”历史条件”

stats.total / stats.turn / stats.last
Cond.stat_at_least("spells_cast", 6)

2017 年做这个抽象,2025 年的《同源》就是零引擎改动。

★★ 11. DSL 覆盖 95%,剩下 5% 留逃生舱

battlecry=SpecialEffect("YOGG_SARON")

追求 100% DSL 覆盖是过度设计。 承认有些卡就是特殊的。

★★ 12. Selector / Condition / Action / Amount 四层分离

它们各自可组合,交叉组合出所有卡牌。任何一层混进另一层的职责,表达力都会打折。


★ 13. RNG 记调用日志,不只记 seed

assert call.domain == len(seq) 这一行是行为变更的最佳探测器。

★ 14. 惰性洗牌

只在抽牌时决定抽到哪张,减少信息泄露面。数学上等价。

★ 15. 循环保护是必须的,不是可选的

MAX_TRIGGER_DEPTHMAX_DEATH_ROUNDS、回合时长上限。你不可能证明新卡不会和老卡形成环。

★ 16. 不变式测试的投入产出比最高

15 条不变式 + 随机对局 fuzzing,能抓住 40% 的 bug。

★ 17. 服务器权威 + 零客户端逻辑

用动画时长掩盖 RTT,换取零回滚代码和零作弊面。

★ 18. 把”待决策”表达成纯数据,不是闭包

PendingChoice(kind="DISCOVER", options=[...], continuation_id="ADD_TO_HAND")

这样断线重连、存档、服务器重启都能恢复。

★ 19. 复制要区分 BASE / FULL / SNAPSHOT 三种模式

否则 3 费的《克隆》能复制 20/20 的随从。

★ 20. 构筑期的复杂度不要渗透进对局引擎

符文、旅客、传奇、双职业——全部在构筑校验里解决,对局引擎无感知。


第 34 章 如果我今天从零开始做一个卡牌引擎

按顺序,这是我会做的事:

第 1 周:核心数据模型

  • Entity(id + tag dict)
  • Tag 枚举(预留大量空位)
  • Zone + ZoneManager(含容量、位置维护)
  • CardDef(frozen dataclass)
  • 单元测试:区域移动的不变式

关键决策点:把 CONTROLLEROWNER 分开(哪怕你现在用不到),把 HEALTH/DAMAGE 分开。

第 2 周:事件与触发

  • EventBus(分桶订阅 + 队列 + 深度保护)
  • EventCtx(字段宁多勿少)
  • TriggerDef(含 zones 字段!)
  • process_deaths

关键决策点TriggerDef.zones 这个字段会在第 3 年帮你实现手牌/牌库里生效的卡。现在加它是免费的。

第 3 周:DSL

  • Selector + 组合子
  • Condition + 布尔代数
  • Action + 控制流(Seq/If/Repeat/ForEach)
  • 20 个原语 Action
  • Amount 表达式

关键决策点:从第一天就用数据结构表达效果,不要用 lambda。哪怕现在麻烦。

第 4 周:光环与附魔

  • AuraBus(脏标记 + 全量重算)
  • 附魔实体 + apply/remove
  • 沉默
  • 变形

第 5 周:出牌流程与关键词

  • play_card 的完整分阶段流程
  • attack 的 11 步
  • 15 个基础关键词

第 6 周:随机与回放

  • GameRNG + 日志
  • ReplayRNG + desync 检测
  • 状态哈希

第 7 周:测试基础设施

  • 不变式检查器
  • 随机对局 fuzzer
  • 黄金回放

第 8 周及以后:卡牌

从这里开始,加新卡应该不需要碰引擎。如果碰了,说明前七周有东西没做对,回去补。


第 35 章 迷你引擎源码

以下是 stoneheart.py 的完整源码,约 2100 行,零依赖,python3 stoneheart.py 直接运行。

自检输出

==========================================================================
stoneheart —— 迷你炉石引擎自检
==========================================================================
PASS 两个食尸鬼同时死亡 两个亡语都触发,蜘蛛坦克受 2 点伤害
PASS 光环沉默致死 沉默光环源后,受伤的随从因上限下降而死亡
PASS 圣盾挡剧毒 圣盾吸收伤害 → 剧毒不生效,随从存活
PASS 亡语召唤位置 亡语衍生物在原随从位置召唤(快照生效)
PASS 触发顺序按上场时间 触发顺序按上场时间(ENTITY_ID),不是战场位置
PASS 复生在亡语之后 复生体以 1 血回归且失去复生标签
PASS 巨型召唤部件 巨型主体带出 2 个触手部件
PASS 战场已满召唤失败 战场满时召唤失败:实体消失,不入墓地,不触发亡语
PASS 突袭不能打脸 突袭随从首回合只能攻击随从
PASS 嘲讽限制目标 有嘲讽时合法目标只剩嘲讽随从
PASS 法术伤害加成 法术伤害 +1 让火球术造成 7 点伤害
PASS 狂乱一次性 狂乱只触发一次,且致死伤害不触发
PASS 超杀 4 攻打 1 血随从触发超杀
PASS 法术迸发 法术迸发触发一次后消耗
PASS 任务完成 任务:使用 3 个法术后获得奖励
PASS 奥秘镜像实体 镜像实体复制对手打出的随从
PASS 过载 过载在下回合锁定水晶
PASS 可交易 可交易:花 1 费洗回牌库并抽一张
PASS 准备 准备:花 4 费换 5 点折扣,本回合锁定,下回合可出
PASS 发现 发现三选一:['火球术', '闪电箭', '奥术智慧']
PASS 疲劳 疲劳伤害递增 1,2,3
PASS 变形保留位置 变形保留 ENTITY_ID 与位置,重置属性
PASS 确定性回放 回放一致:RNG 调用完全复现
PASS 随机对局不变式 40 局随机对局 / 900+ 个回合,全部不变式通过
--------------------------------------------------------------------------
24 passed, 0 failed
==========================================================================

40 局随机对局在单核 Python 上跑完只需 0.47 秒,说明这个架构在没有任何优化的情况下性能就足够做开发和测试。

覆盖的机制清单

范式已实现
静态标签嘲讽、冲锋、突袭、圣盾、潜行、剧毒、吸血、风怒、冻结、免疫、不可选中、复生、法术伤害
触发器战吼、连击、亡语、狂乱、超杀、荣誉击杀、法术迸发、回合开始/结束、召唤时、奥秘
出牌钩子过载、连击判定、终曲快照、可交易、准备
选择型发现(含策略注入,AI 模式不挂起)
状态机任务(通用 ProgressionDef,可表达任务链/法术石/腐蚀/挖掘)
多实体巨型(colossal_parts
系统光环重算、附魔、沉默、变形、死亡结算、疲劳、烧牌、确定性 RNG、回放、事件流记录

源码

见随本文档一同交付的 stoneheart.py。以下是关键片段的导读,完整文件请直接运行。

骨架

class Game:
def __init__(self, seed=0, verbose=False, policy=None):
self.rng = GameRNG(seed) # 唯一随机源
self.entities = {} # id -> Entity
self.zones = ZoneManager(self) # 唯一区域变化入口
self.events = EventBus(self) # 触发队列
self.auras = AuraBus(self) # 光环重算
self.players = [Player(self, 0), Player(self, 1)]
self.history = [] # PowerHistory 雏形
self.policy = policy # 决策注入点(发现/抉择)

触发顺序的实现(铁律一)

    def collect(self, ctx: EventCtx) -> list:
out = []
for owner, td in self.subs.get(ctx.kind, ()):
if ctx.kind != Event.DEATH and owner[Tag.ZONE] not in td.zones:
continue
if td.source_only and ctx.entity is not owner: continue
if owner.has(Tag.SILENCED): continue
if td.once and owner.has(Tag.TRIGGERED_ONCE): continue
c = Ctx(source=owner, controller=owner[Tag.CONTROLLER], event=ctx)
if td.condition and not td.condition.eval(self.game, owner, c):
continue
out.append((owner, td))
out.sort(key=lambda x: x[0][Tag.ENTITY_ID]) # ★ 上场顺序
return out

死亡结算的六个步骤

    def process_deaths(self):
while True:
dying = [收集 current_health <= 0 或 TO_BE_DESTROYED 的随从]
if not dying: return
dying.sort(key=lambda e: e[Tag.ENTITY_ID]) # ① 排序
for e in dying: e.death_snapshot = DeathSnapshot(...) # ② 快照
for e in dying: self.emit_now(Event.PROPOSED_DEATH, ...) # ③ 免死
for e in dying: self.zones.move(e, Zone.GRAVEYARD) # ④ 全部入墓
self.auras.mark_dirty(); self.auras.recompute()
for e in dying: self.emit(Event.DEATH, ...) # ⑤ 亡语
self.events.resolve()
for e in dying: 复生(e) # ⑥ 复生在后
for e in dying: self.emit(Event.AFTER_DEATH, ...)
self.events.resolve()
# 循环,直到没有新死亡

伤害管线(圣盾 / 剧毒 / 吸血的正确顺序)

    def damage(self, target, amount, source=None) -> int:
if amount <= 0 or not in_play(target): return 0
if target.has(Tag.IMMUNE): return 0

ctx = self.emit_now(Event.PRE_DAMAGE, ..., mutable_amount=amount)
amount = max(0, ctx.mutable_amount)
if amount == 0: return 0

if target.has(Tag.DIVINE_SHIELD): # ★ 圣盾在这里
target[Tag.DIVINE_SHIELD] = 0
return 0 # ★ 返回 0 → 剧毒/吸血都不生效

before = target.current_health
if target.is_hero and target[Tag.ARMOR] > 0:
absorbed = min(target[Tag.ARMOR], amount)
target[Tag.ARMOR] -= absorbed; amount -= absorbed
if amount == 0: return 0

target[Tag.DAMAGE] += amount
self.emit(Event.DAMAGE, ..., amount=amount,
fatal=target.current_health <= 0,
target_health_before=before) # ★ 供超杀/荣誉击杀使用

if source is not None and source.has(Tag.LIFESTEAL):
self.heal(self.player(source[Tag.CONTROLLER]).hero, amount)
if source is not None and source.has(Tag.POISONOUS) and target.is_minion:
target[Tag.TO_BE_DESTROYED] = 1
return amount

通用状态机(一个数据结构表达十二个关键词)

@dataclass(frozen=True)
class ProgressionDef:
counter_event: Event
thresholds: tuple # 各阶段阈值
condition: Condition | None
zones: frozenset # 在哪个区域计数(手牌/奥秘区/战场)
on_stage: tuple # 各阶段奖励
upgrade_to: tuple # 各阶段变形目标
complete_action: Action | None


class Progress(Action):
def run(self, g, c):
e = c.source
e[Tag.PROGRESS] += 1
pd, stage = e.defn.progression, e.progress_stage
if stage >= len(pd.thresholds): return
if e[Tag.PROGRESS] >= pd.thresholds[stage]:
e[Tag.PROGRESS] = 0
e.progress_stage += 1
if pd.on_stage[stage]: pd.on_stage[stage].run(g, c)
if pd.upgrade_to[stage]: g.transform(e, pd.upgrade_to[stage])
elif 全部完成: pd.complete_action.run(g, c)

用它表达不同关键词:

# 任务(2017)
ProgressionDef(counter_event=Event.SPELL_CAST, thresholds=(6,),
zones={Zone.SECRET}, complete_action=AddToHand("REWARD"))

# 法术石(2017)
ProgressionDef(counter_event=Event.ARMOR_GAINED, thresholds=(3,),
zones={Zone.HAND}, upgrade_to=("SPELLSTONE_T1",))

# 任务链(2021)
ProgressionDef(counter_event=Event.MINION_PLAYED, thresholds=(3, 3, 3),
zones={Zone.SECRET},
upgrade_to=("STAGE2", "STAGE3", None),
complete_action=SummonReward())

# 灌注 Infuse(2022)
ProgressionDef(counter_event=Event.DEATH, condition=Cond.DEAD_FRIENDLY_MINION,
thresholds=(4,), zones={Zone.HAND},
upgrade_to=("INFUSED_VERSION",))

# 挖掘(2023)
ProgressionDef(counter_event=Event.EXCAVATE, thresholds=(1,1,1,1),
on_stage=(Tier1(), Tier2(), Tier3(), Legendary()))

# 先驱(2026)
ProgressionDef(counter_event=Event.HERALD_USED, thresholds=(2, 2),
on_stage=(UpgradeColossal(1), UpgradeColossal(2)))

一个 40 行的通用组件,覆盖了九年间的六个关键词。 这就是这份文档想传达的核心:好的抽象让新机制的边际成本趋近于零。


第 36 章 尾声:一个系统能活十二年的原因

回看这十二年,炉石引擎能持续演进而不崩塌,我认为有四个原因:

一、它在第一年就付出了”过度设计”的代价

2014 年的引擎里就有:

  • CONTROLLEROWNER 分离(2026 年才被”在对手场上打出随从”用到)
  • SETASIDE 区(2022 年才被巨型充分利用)
  • ZONE_POSITION 对手牌也维护(2020 年才被流放用到)
  • 卡牌类型是 tag(2022 年才加地标)
  • 附魔是实体(2018 年才被磁力充分利用)

这些在 2014 年看起来都是”用不上的复杂度”。但它们是这个系统能活到 2026 年的唯一原因。

二、它敢于用数据修正设计错误

  • 2019 年把几十张卡的《冲锋》改成《突袭》
  • 2019 年把《激怒》整体换成”受伤后永久 +X”
  • 2016 年把《剧毒》从触发式改成伤害管线式

每一次都是改数据/改一处引擎逻辑,而不是改几十处卡牌代码。

三、它接受了 5% 的特例

《尤格-萨隆》、《魔法契约》、《砰砰博士》的某些版本,都是硬编码的。没有强行让 DSL 表达一切。

四、它把复杂度推到了正确的层

复杂度推到哪里
构筑规则(符文、旅客、双职业)构筑校验
动画与表现客户端
随机的公平性服务器权威 + 日志
卡牌的行为数据(DSL)
规则的交互引擎(少量、稳定)

引擎本身保持很小。 这是它能被少数几个人维护十二年的原因。


最后一句

如果你从这份文档里只带走一句话,我希望是这句:

不要为今天的卡牌设计引擎,要为你还没想到的那张卡设计引擎。

具体的做法是:每当你要为一张卡写特殊逻辑时,先问一句”这张卡的机制能不能表达成一个更一般的东西”。如果能,做那个一般的东西——哪怕现在只有一张卡用它。三年后你会有二十张卡用它。


附录 A:关键词速查表(按年份)

年份版本新关键词范式
2014.03基础/经典嘲讽、冲锋、圣盾、风怒、潜行、法术伤害、过载、连击、战吼、亡语、奥秘、抉择、冻结、沉默、免疫、激怒全部六种
2014.07纳克萨玛斯(亡语主题)
2014.12地精大战侏儒超级风怒、机械、零件静态标签
2015.04黑石山(龙 synergy,手牌条件)触发器
2015.08冠军的试炼励志、对决触发器
2015.11探险者协会发现选择型
2016.04上古之神克苏恩计数器、腐化状态机
2016.08卡拉赞之夜
2016.12加基森剧毒(关键词化)、玉莲花、手牌 buff静态标签
2017.04安戈洛适应任务 ★、元素选择型 + 状态机
2017.08冰封王座吸血、英雄牌静态标签
2017.12狗头人召募、法术石状态机
2018.04女巫森林突袭 ★、回响、单双数、开局时静态标签 + 出牌钩子
2018.08砰砰计划磁力 ★、Omega、Project多实体
2018.12拉斯塔哈超杀触发器
2019.04暗影崛起双生法术、跟班、阴谋出牌钩子 + 状态机
2019.08奥丹姆奇兵复生静态标签
2019.12巨龙降临探底、支线任务状态机
2020.04外域的灰烬流放 ★、至尊、恶魔猎手出牌钩子
2020.08通灵学园法术迸发、双职业触发器
2020.11达拉然腐蚀状态机
2021.03贫瘠之地狂乱、阶级法术、法术学派触发器
2021.08暴风城任务链可交易状态机 + 新动作
2021.12奥山荣誉击杀触发器
2022.04沉没之城打捞巨型选择型 + 多实体
2022.08纳斯利亚堡地标 ★、灌注新卡牌类型 + 状态机
2022.12巫妖王符文、尸块法力渴求、死亡骑士新资源
2023.04传说节日终曲出牌钩子
2023.08泰坦泰坦 ★、锻造新动作
2023.11荒芜之地挖掘快速射击状态机
2024.03维兹班工坊迷你化组合
2024.07天空邮轮旅客构筑期
2024.11浩瀚之外星舰多实体
2025.03翡翠梦境灌注(Imbue)黑暗恩赐、抉择全职业化状态机 + 选择型
2025.07失落的安戈洛之城同源、故事卡、地图卡触发器
2025.11时光之外倒带 ★、传奇撤销/重做 + 构筑期
2026.03大灾变先驱碎裂 ★、巨型回归状态机 + 多实体
2026.07紫罗兰监狱越狱准备 ★、破规传说、伪装随从新动作

★ = 引擎层面的重大新增能力

附录 B:术语对照

中文英文引擎概念
实体Entity有 ID 的状态容器
标签Tag / GameTag实体的一个整数属性
区域Zone实体所处的逻辑位置
附魔Enchantment附着在实体上的持久修饰实体
光环Aura由源实体持续提供的条件性修饰
触发器Trigger事件订阅
死亡结算阶段Death Phase / SBA批量处理死亡的阶段
快照Snapshot延迟效果所需上下文的即时拷贝
变化流PowerHistory服务器发给客户端的状态变化序列
衍生物Token不可收集的、运行时生成的卡
逃生舱Special EffectDSL 无法表达时的硬编码入口

附录 C:参考与来源

事实性内容(版本时间线、关键词文本、规则裁定)来源:

关于实现细节的免责声明:本文中所有引擎实现(类名、数据结构、算法流程)都是基于公开可观察行为的重构与推断,不是暴雪的源代码。凡标注”推断”处尤其如此。真实的炉石客户端是 Unity + C#,其内部结构与本文的 Python 重构在概念上对应但在实现上不同。

本文的价值不在于”炉石具体是怎么写的”,而在于”要支撑这样的行为,一个引擎必须具备什么性质”。这些性质是可推导的,也是可迁移的。


文档完


附录 D:stoneheart.py 完整源码

零依赖,python3 stoneheart.py 直接运行,24 项自检全部通过。

"""
stoneheart —— 一个炉石传说风格的迷你卡牌引擎
=================================================
用于演示文档《炉石传说引擎深度解剖》中的所有核心机制。

设计目标:
* 单文件、零依赖、可直接运行
* 覆盖六大关键词范式:静态标签 / 触发器 / 出牌钩子 / 选择型 / 状态机 / 多实体
* 确定性随机、事件流记录、完整死亡结算

运行:python3 stoneheart.py
"""
from __future__ import annotations

import random
import itertools
from collections import defaultdict, deque
from dataclasses import dataclass, field, replace
from enum import IntEnum, auto
from typing import Any, Callable, Iterable

# =============================================================================
# 1. 枚举与常量
# =============================================================================


class Zone(IntEnum):
INVALID = 0
PLAY = 1
DECK = 2
HAND = 3
GRAVEYARD = 4
SETASIDE = 5
SECRET = 6
HERO = 7


class CardType(IntEnum):
INVALID = 0
MINION = 1
SPELL = 2
WEAPON = 3
HERO = 4
HERO_POWER = 5
ENCHANTMENT = 6


class Tag(IntEnum):
CARD_ID = auto()
ENTITY_ID = auto()
CONTROLLER = auto()
OWNER = auto()
ZONE = auto()
ZONE_POSITION = auto()
CARDTYPE = auto()
CARDRACE = auto()
SPELL_SCHOOL = auto()

COST = auto()
ATK = auto()
HEALTH = auto()
DAMAGE = auto()
ARMOR = auto()
DURABILITY = auto()

TAUNT = auto()
DIVINE_SHIELD = auto()
CHARGE = auto()
RUSH = auto()
STEALTH = auto()
POISONOUS = auto()
LIFESTEAL = auto()
WINDFURY = auto()
FROZEN = auto()
IMMUNE = auto()
ELUSIVE = auto()
REBORN = auto()
SILENCED = auto()
SPELLPOWER = auto()
TRADEABLE = auto()
PREPARE = auto()

EXHAUSTED = auto()
NUM_ATTACKS = auto()
NUM_TURNS_IN_PLAY = auto()
TO_BE_DESTROYED = auto()
TEMPORARY = auto()
TRIGGERED_ONCE = auto()
QUEST_PROGRESS = auto()
QUEST_TOTAL = auto()
PROGRESS = auto()
PREPARE_DISCOUNT = auto()
PREPARED = auto()
LOCKED_UNTIL_TURN = auto()
DRAWN_ON_TURN = auto()
ATTACHED = auto()

MANA_CRYSTALS = auto()
MANA_AVAILABLE = auto()
OVERLOAD_OWED = auto()
OVERLOAD_LOCKED = auto()
FATIGUE = auto()
COMBO_ACTIVE = auto()


IN_PLAY_ZONES = (Zone.PLAY, Zone.HERO)


def in_play(e) -> bool:
return e.tags.get(Tag.ZONE, Zone.INVALID) in IN_PLAY_ZONES


SILENCEABLE_TAGS = {
Tag.TAUNT, Tag.DIVINE_SHIELD, Tag.CHARGE, Tag.RUSH, Tag.STEALTH,
Tag.POISONOUS, Tag.LIFESTEAL, Tag.WINDFURY, Tag.SPELLPOWER,
Tag.REBORN, Tag.ELUSIVE,
}

TRANSFORM_PRESERVED = {
Tag.ENTITY_ID, Tag.ZONE, Tag.ZONE_POSITION, Tag.CONTROLLER, Tag.OWNER,
Tag.EXHAUSTED, Tag.NUM_ATTACKS, Tag.FROZEN, Tag.NUM_TURNS_IN_PLAY,
}

KEYWORD_TO_TAG = {
"TAUNT": Tag.TAUNT, "DIVINE_SHIELD": Tag.DIVINE_SHIELD,
"CHARGE": Tag.CHARGE, "RUSH": Tag.RUSH, "STEALTH": Tag.STEALTH,
"POISONOUS": Tag.POISONOUS, "LIFESTEAL": Tag.LIFESTEAL,
"WINDFURY": Tag.WINDFURY, "ELUSIVE": Tag.ELUSIVE, "REBORN": Tag.REBORN,
"TRADEABLE": Tag.TRADEABLE, "PREPARE": Tag.PREPARE,
}


class Event(IntEnum):
GAME_START = auto()
TURN_START = auto()
TURN_END = auto()
DRAW = auto()
CARD_BURNED = auto()
PLAY_CARD = auto()
MINION_PLAYED = auto()
SPELL_CAST = auto()
AFTER_SPELL = auto()
PRE_SUMMON = auto()
SUMMON = auto()
SUMMON_FAILED = auto()
PROPOSED_ATTACK = auto()
ATTACK = auto()
AFTER_ATTACK = auto()
PRE_DAMAGE = auto()
DAMAGE = auto()
HEAL = auto()
PROPOSED_DEATH = auto()
DEATH = auto()
AFTER_DEATH = auto()
HERO_POWER_USED = auto()
SECRET_REVEALED = auto()
CARD_TRADED = auto()
CARD_PREPARED = auto()


# =============================================================================
# 2. 确定性随机
# =============================================================================


class GameRNG:
"""所有游戏随机的唯一入口。记录调用日志以支持回放与 desync 检测。"""

def __init__(self, seed: int):
self.seed = seed
self._rng = random.Random(seed)
self.log: list[tuple] = []

def randrange(self, n: int) -> int:
i = self._rng.randrange(n)
self.log.append(("randrange", n, i))
return i

def choice(self, seq):
seq = list(seq)
if not seq:
raise ValueError("choice from empty sequence")
return seq[self.randrange(len(seq))]

def sample(self, seq, k):
seq = list(seq)
k = min(k, len(seq))
idx = self._rng.sample(range(len(seq)), k)
self.log.append(("sample", len(seq), tuple(idx)))
return [seq[i] for i in idx]

def randint(self, a: int, b: int) -> int:
v = self._rng.randint(a, b)
self.log.append(("randint", (a, b), v))
return v

def shuffle(self, lst: list):
perm = list(range(len(lst)))
self._rng.shuffle(perm)
self.log.append(("shuffle", len(lst), tuple(perm)))
lst[:] = [lst[i] for i in perm]


class ReplayRNG(GameRNG):
"""回放模式:从日志读取,任何偏差立刻报错。"""

def __init__(self, log):
self.log_in = list(log)
self.idx = 0
self.log = []

def _next(self, op, domain):
if self.idx >= len(self.log_in):
raise AssertionError("RNG log exhausted — engine consumed more randomness")
rec = self.log_in[self.idx]
self.idx += 1
if rec[0] != op or rec[1] != domain:
raise AssertionError(
f"RNG desync at call #{self.idx}: log has {rec[:2]}, engine asked {(op, domain)}")
return rec[2]

def randrange(self, n): return self._next("randrange", n)
def sample(self, seq, k):
seq = list(seq)
idx = self._next("sample", len(seq))
return [seq[i] for i in idx]
def randint(self, a, b): return self._next("randint", (a, b))
def shuffle(self, lst):
perm = self._next("shuffle", len(lst))
lst[:] = [lst[i] for i in perm]


# =============================================================================
# 3. Selector / Condition / Amount —— 数据驱动的三根支柱
# =============================================================================


@dataclass
class Ctx:
source: "Entity"
controller: int
target: "Entity | None" = None
event: "EventCtx | None" = None
choose: int = 0
finale: bool = False
stored: dict = field(default_factory=dict)


class Selector:
def eval(self, game, ctx) -> list["Entity"]:
raise NotImplementedError

def __add__(self, o): return _Union(self, o)
def __sub__(self, o): return _Diff(self, o)
def where(self, c): return _Filtered(self, c)
def random(self, n=1): return _RandomPick(self, n)
def top(self, n, key): return _TopN(self, n, key)


class _Union(Selector):
def __init__(self, a, b): self.a, self.b = a, b
def eval(self, g, c):
out = list(self.a.eval(g, c))
for e in self.b.eval(g, c):
if e not in out:
out.append(e)
return out


class _Diff(Selector):
def __init__(self, a, b): self.a, self.b = a, b
def eval(self, g, c):
ban = set(id(x) for x in self.b.eval(g, c))
return [e for e in self.a.eval(g, c) if id(e) not in ban]


class _Filtered(Selector):
def __init__(self, inner, cond): self.inner, self.cond = inner, cond
def eval(self, g, c):
return [e for e in self.inner.eval(g, c) if self.cond.eval(g, e, c)]


class _RandomPick(Selector):
def __init__(self, inner, n): self.inner, self.n = inner, n
def eval(self, g, c):
pool = self.inner.eval(g, c)
if not pool: return []
return g.rng.sample(pool, min(self.n, len(pool)))


class _TopN(Selector):
def __init__(self, inner, n, key): self.inner, self.n, self.key = inner, n, key
def eval(self, g, c):
pool = self.inner.eval(g, c)
if not pool: return []
best = max(self.key(e) for e in pool)
tied = [e for e in pool if self.key(e) == best]
return tied if len(tied) <= self.n else g.rng.sample(tied, self.n)


class AllMinions(Selector):
def eval(self, g, c):
return [e for pid in (0, 1) for e in g.zones.get(pid, Zone.PLAY)
if e[Tag.CARDTYPE] == CardType.MINION]


class AllHeroes(Selector):
def eval(self, g, c):
return [g.player(0).hero, g.player(1).hero]


class Friendly(Selector):
def __init__(self, inner): self.inner = inner
def eval(self, g, c):
return [e for e in self.inner.eval(g, c) if e[Tag.CONTROLLER] == c.controller]


class Enemy(Selector):
def __init__(self, inner): self.inner = inner
def eval(self, g, c):
return [e for e in self.inner.eval(g, c) if e[Tag.CONTROLLER] != c.controller]


class SelfSel(Selector):
def eval(self, g, c): return [c.source]


class TargetSel(Selector):
def eval(self, g, c): return [c.target] if c.target is not None else []


class EventEntitySel(Selector):
def eval(self, g, c):
return [c.event.entity] if (c.event and c.event.entity) else []


class InZone(Selector):
def __init__(self, zone, enemy=False): self.zone, self.enemy = zone, enemy
def eval(self, g, c):
pid = (1 - c.controller) if self.enemy else c.controller
return list(g.zones.get(pid, self.zone))


class Sel:
ALL_MINIONS = AllMinions()
FRIENDLY_MINIONS = Friendly(AllMinions())
ENEMY_MINIONS = Enemy(AllMinions())
OTHER_FRIENDLY = _Diff(Friendly(AllMinions()), SelfSel())
ALL_CHARACTERS = _Union(AllMinions(), AllHeroes())
ENEMY_CHARACTERS = Enemy(_Union(AllMinions(), AllHeroes()))
SELF = SelfSel()
TARGET = TargetSel()
EVENT_ENTITY = EventEntitySel()
MY_HAND = InZone(Zone.HAND)
MY_DECK = InZone(Zone.DECK)


# ---------------------------------------------------------------- Conditions
class Condition:
def eval(self, game, e, ctx) -> bool: raise NotImplementedError
def __and__(self, o): return _And(self, o)
def __or__(self, o): return _Or(self, o)
def __invert__(self): return _Not(self)


class _And(Condition):
def __init__(self, a, b): self.a, self.b = a, b
def eval(self, g, e, c): return self.a.eval(g, e, c) and self.b.eval(g, e, c)


class _Or(Condition):
def __init__(self, a, b): self.a, self.b = a, b
def eval(self, g, e, c): return self.a.eval(g, e, c) or self.b.eval(g, e, c)


class _Not(Condition):
def __init__(self, a): self.a = a
def eval(self, g, e, c): return not self.a.eval(g, e, c)


class Always(Condition):
def eval(self, g, e, c): return True


class HasTag(Condition):
def __init__(self, t): self.t = t
def eval(self, g, e, c): return bool(e[self.t])


class IsRace(Condition):
def __init__(self, r): self.r = r
def eval(self, g, e, c): return e[Tag.CARDRACE] in (self.r, "ALL")


class IsMinion(Condition):
def eval(self, g, e, c): return e[Tag.CARDTYPE] == CardType.MINION


class AttackAtMost(Condition):
def __init__(self, n): self.n = n
def eval(self, g, e, c): return e[Tag.ATK] <= self.n


class MyTurn(Condition):
def eval(self, g, e, c): return g.current == e[Tag.CONTROLLER]


class ControllerIsOwner(Condition):
"""事件的发起方是这个触发器的主人"""
def eval(self, g, e, c):
ev = c.event
if ev is None: return False
pid = ev.player
if pid is None and ev.entity is not None:
pid = ev.entity[Tag.CONTROLLER]
if pid is None and ev.source is not None:
pid = ev.source[Tag.CONTROLLER]
return pid == e[Tag.CONTROLLER]


class EventEntityIsSelf(Condition):
def eval(self, g, e, c): return c.event is not None and c.event.entity is e


class EventTargetIsSelf(Condition):
def eval(self, g, e, c): return c.event is not None and c.event.target is e


class Survived(Condition):
def eval(self, g, e, c):
return c.event is not None and c.event.amount > 0 and not c.event.fatal


class Overkill(Condition):
def eval(self, g, e, c):
ev = c.event
return (ev is not None and ev.source is e
and ev.amount > ev.target_health_before
and g.current == e[Tag.CONTROLLER])


class HonorableKill(Condition):
def eval(self, g, e, c):
ev = c.event
return (ev is not None and ev.source is e
and ev.amount == ev.target_health_before and ev.fatal)


class DeadIsFriendlyMinion(Condition):
def eval(self, g, e, c):
ev = c.event
return (ev is not None and ev.entity is not None
and ev.entity[Tag.CARDTYPE] == CardType.MINION
and ev.entity[Tag.CONTROLLER] == e[Tag.CONTROLLER])


class StatAtLeast(Condition):
"""统一的历史统计查询 —— 见第 23 章的建议"""
def __init__(self, key, n, scope="total"):
self.key, self.n, self.scope = key, n, scope
def eval(self, g, e, c):
st = g.player(e[Tag.CONTROLLER]).stats
d = {"total": st.total, "turn": st.turn, "last": st.last}[self.scope]
return d.get(self.key, 0) >= self.n


class Cond:
ALWAYS = Always()
MY_TURN = MyTurn()
OWNER = ControllerIsOwner()
SELF_IS_EVENT_ENTITY = EventEntityIsSelf()
SELF_IS_EVENT_TARGET = EventTargetIsSelf()
SURVIVED = Survived()
OVERKILL = Overkill()
HONORABLE_KILL = HonorableKill()
DEAD_FRIENDLY_MINION = DeadIsFriendlyMinion()


# ------------------------------------------------------------------- Amounts
class Amount:
def eval(self, g, c) -> int: raise NotImplementedError


class CountOf(Amount):
def __init__(self, sel): self.sel = sel
def eval(self, g, c): return len(self.sel.eval(g, c))


class TagOf(Amount):
def __init__(self, sel, tag): self.sel, self.tag = sel, tag
def eval(self, g, c):
es = self.sel.eval(g, c)
return es[0][self.tag] if es else 0


class RandomRange(Amount):
def __init__(self, a, b): self.a, self.b = a, b
def eval(self, g, c): return g.rng.randint(self.a, self.b)


def amt(a, g, c) -> int:
return a.eval(g, c) if isinstance(a, Amount) else int(a)


# =============================================================================
# 4. Action —— 效果语言
# =============================================================================


class Action:
def run(self, game, ctx): raise NotImplementedError


class Seq(Action):
def __init__(self, *acts): self.acts = acts
def run(self, g, c):
for a in self.acts: a.run(g, c)


class If(Action):
def __init__(self, cond, then, otherwise=None):
self.cond, self.then, self.otherwise = cond, then, otherwise
def run(self, g, c):
if self.cond.eval(g, c.source, c): self.then.run(g, c)
elif self.otherwise: self.otherwise.run(g, c)


class Repeat(Action):
def __init__(self, n, act): self.n, self.act = n, act
def run(self, g, c):
for _ in range(amt(self.n, g, c)): self.act.run(g, c)


class ForEach(Action):
def __init__(self, sel, act): self.sel, self.act = sel, act
def run(self, g, c):
for e in list(self.sel.eval(g, c)):
self.act.run(g, replace(c, target=e))


class Damage(Action):
def __init__(self, sel, amount): self.sel, self.amount = sel, amount
def run(self, g, c):
targets = list(self.sel.eval(g, c)) # ★ 早绑定快照
n = amt(self.amount, g, c)
if c.source[Tag.CARDTYPE] == CardType.SPELL:
n += g.spellpower(c.controller)
for t in targets:
if not in_play(t): continue
g.damage(t, n, source=c.source)


class Heal(Action):
def __init__(self, sel, amount): self.sel, self.amount = sel, amount
def run(self, g, c):
n = amt(self.amount, g, c)
for t in list(self.sel.eval(g, c)): g.heal(t, n)


class Destroy(Action):
def __init__(self, sel): self.sel = sel
def run(self, g, c):
for e in list(self.sel.eval(g, c)): e[Tag.TO_BE_DESTROYED] = 1


class Draw(Action):
def __init__(self, n=1): self.n = n
def run(self, g, c): g.draw(c.controller, amt(self.n, g, c))


class Summon(Action):
def __init__(self, card_id, n=1, at_death_position=False):
self.card_id, self.n, self.at_death_pos = card_id, n, at_death_position
def run(self, g, c):
pos = None
if self.at_death_pos and c.event and c.event.snapshot:
pos = c.event.snapshot.position - 1
for _ in range(amt(self.n, g, c)):
g.summon(self.card_id, c.controller, pos)


class Buff(Action):
def __init__(self, sel, atk=0, hp=0, keywords=()):
self.sel, self.atk, self.hp, self.kws = sel, atk, hp, tuple(keywords)
def run(self, g, c):
for e in list(self.sel.eval(g, c)):
g.apply_enchantment(e, atk=amt(self.atk, g, c), hp=amt(self.hp, g, c),
keywords=self.kws)


class GiveKeyword(Action):
def __init__(self, sel, *kws): self.sel, self.kws = sel, kws
def run(self, g, c):
for e in list(self.sel.eval(g, c)):
for k in self.kws: e[KEYWORD_TO_TAG[k]] = 1


class Freeze(Action):
def __init__(self, sel): self.sel = sel
def run(self, g, c):
for e in list(self.sel.eval(g, c)): e[Tag.FROZEN] = 1


class SilenceAct(Action):
def __init__(self, sel): self.sel = sel
def run(self, g, c):
for e in list(self.sel.eval(g, c)): g.silence(e)


class Overload(Action):
def __init__(self, n): self.n = n
def run(self, g, c): g.player(c.controller)[Tag.OVERLOAD_OWED] += amt(self.n, g, c)


class GainArmor(Action):
def __init__(self, n): self.n = n
def run(self, g, c):
h = g.player(c.controller).hero
h[Tag.ARMOR] += amt(self.n, g, c)


class AddToHand(Action):
def __init__(self, card_id, n=1): self.card_id, self.n = card_id, n
def run(self, g, c):
for _ in range(amt(self.n, g, c)):
g.add_to_hand(c.controller, self.card_id)


class Discover(Action):
"""选择型范式。模拟/无交互时由 policy 决定。"""
def __init__(self, pool: list[str], count=3, then: Action | None = None):
self.pool, self.count, self.then = pool, count, then
def run(self, g, c):
if not self.pool: return
picks = g.rng.sample(self.pool, min(self.count, len(self.pool)))
chosen = g.ask_choice(c.controller, picks, kind="DISCOVER")
act = self.then or AddToHand(chosen)
if isinstance(act, AddToHand) and self.then is None:
g.add_to_hand(c.controller, chosen)
else:
act.run(g, replace(c, stored={**c.stored, "discovered": chosen}))


class Progress(Action):
"""状态机范式的通用实现:计数 → 阈值 → 奖励/升级"""
def run(self, g, c):
e = c.source
e[Tag.PROGRESS] += 1
pd = e.defn.progression
stage = e.progress_stage
if stage >= len(pd.thresholds): return
if e[Tag.PROGRESS] >= pd.thresholds[stage]:
e[Tag.PROGRESS] = 0
e.progress_stage += 1
if stage < len(pd.on_stage) and pd.on_stage[stage]:
pd.on_stage[stage].run(g, c)
if stage < len(pd.upgrade_to) and pd.upgrade_to[stage]:
g.transform(e, pd.upgrade_to[stage])
elif e.progress_stage >= len(pd.thresholds) and pd.complete_action:
pd.complete_action.run(g, c)
if e[Tag.ZONE] == Zone.SECRET:
g.zones.move(e, Zone.GRAVEYARD)
g.events.unregister_all(e)


class RevealSecret(Action):
def run(self, g, c):
s = c.source
g.emit_now(Event.SECRET_REVEALED, entity=s)
g.events.unregister_all(s)
g.zones.move(s, Zone.GRAVEYARD)


class SpecialEffect(Action):
REGISTRY: dict[str, Callable] = {}
def __init__(self, name): self.name = name
def run(self, g, c): SpecialEffect.REGISTRY[self.name](g, c)


def special(name):
def deco(f):
SpecialEffect.REGISTRY[name] = f
return f
return deco


# =============================================================================
# 5. 卡牌定义
# =============================================================================


@dataclass(frozen=True)
class TriggerDef:
on: Event
action: Action
condition: Condition | None = None
zones: frozenset = frozenset({Zone.PLAY})
once: bool = False
source_only: bool = False


@dataclass(frozen=True)
class AuraDef:
selector: Selector
tag: Tag
amount: int = 0
condition: Condition | None = None
include_self: bool = False


@dataclass(frozen=True)
class ProgressionDef:
counter_event: Event
thresholds: tuple
condition: Condition | None = None
zones: frozenset = frozenset({Zone.SECRET})
on_stage: tuple = ()
upgrade_to: tuple = ()
complete_action: Action | None = None


@dataclass(frozen=True)
class CardDef:
card_id: str
name: str
cost: int = 0
card_type: CardType = CardType.MINION
attack: int = 0
health: int = 0
race: str | None = None
spell_school: str | None = None
text: str = ""
keywords: frozenset = frozenset()
battlecry: Action | None = None
combo: Action | None = None
deathrattle: Action | None = None
spell: Action | None = None
triggers: tuple = ()
auras: tuple = ()
progression: ProgressionDef | None = None
targeting: Selector | None = None
spellpower: int = 0
colossal_parts: tuple = ()
magnetic: bool = False
upgrade_to: str | None = None

def base_tags(self) -> dict:
t = {
Tag.CARD_ID: self.card_id,
Tag.CARDTYPE: self.card_type,
Tag.COST: self.cost,
Tag.ATK: self.attack,
Tag.HEALTH: self.health,
Tag.CARDRACE: self.race,
Tag.SPELL_SCHOOL: self.spell_school,
Tag.SPELLPOWER: self.spellpower,
}
for kw in self.keywords:
if kw in KEYWORD_TO_TAG:
t[KEYWORD_TO_TAG[kw]] = 1
return t


CARD_DB: dict[str, CardDef] = {}


def register(card: CardDef) -> CardDef:
CARD_DB[card.card_id] = card
return card


# =============================================================================
# 6. 实体
# =============================================================================


class Entity:
__slots__ = ("id", "game", "tags", "enchantments", "death_snapshot",
"progress_stage", "is_echo_copy", "death_prevented",
"ds_absorbed", "reborn_used", "magnetic_stack")

def __init__(self, eid: int, game: "Game", defn: CardDef, controller: int):
self.id = eid
self.game = game
self.tags: dict = dict(defn.base_tags())
self.tags[Tag.ENTITY_ID] = eid
self.tags[Tag.CONTROLLER] = controller
self.tags[Tag.OWNER] = controller
self.tags[Tag.ZONE] = Zone.SETASIDE
self.enchantments: list = []
self.death_snapshot = None
self.progress_stage = 0
self.is_echo_copy = False
self.death_prevented = False
self.ds_absorbed = False
self.reborn_used = False
self.magnetic_stack: list = []

# ---- tag ----
def __getitem__(self, t): return self.tags.get(t, 0)

def __setitem__(self, t, v):
old = self.tags.get(t, 0)
if old == v: return
self.tags[t] = v
self.game.record_change(self, t, old, v)

def has(self, t) -> bool: return bool(self.tags.get(t, 0))

@property
def defn(self) -> CardDef: return CARD_DB[self.tags[Tag.CARD_ID]]

@property
def current_health(self) -> int:
return self[Tag.HEALTH] - self[Tag.DAMAGE]

@property
def is_minion(self) -> bool: return self[Tag.CARDTYPE] == CardType.MINION

@property
def is_hero(self) -> bool: return self[Tag.CARDTYPE] == CardType.HERO

def __repr__(self):
return (f"<{self.defn.name}#{self.id} {self[Tag.ATK]}/{self.current_health}"
f" z={Zone(self[Tag.ZONE]).name}>")


@dataclass
class DeathSnapshot:
entity_id: int
card_id: str
controller: int
position: int
attack: int
health: int
race: str | None


# =============================================================================
# 7. 区域管理
# =============================================================================


class ZoneManager:
CAP = {Zone.PLAY: 7, Zone.HAND: 10, Zone.SECRET: 5}

def __init__(self, game): self.game = game; self._z = {}

def get(self, pid: int, zone: Zone) -> list:
return self._z.setdefault((pid, zone), [])

def move(self, e: Entity, to: Zone, position: int | None = None) -> bool:
src, pid = e[Tag.ZONE], e[Tag.CONTROLLER]
dst = self.get(pid, to)
cap = self.CAP.get(to)
if cap is not None and len(dst) >= cap:
if to == Zone.HAND:
self.game.emit(Event.CARD_BURNED, entity=e, player=pid)
return self.move(e, Zone.GRAVEYARD)
if to == Zone.PLAY:
self.game.emit(Event.SUMMON_FAILED, entity=e)
self._detach(e)
return False
return False

if src != Zone.INVALID:
old = self.get(pid, src)
if e in old:
old.remove(e)
self._reindex(old)
# 离开战场时清除伤害与死亡标记
if src == Zone.PLAY and to != Zone.PLAY:
e.tags[Tag.DAMAGE] = 0
e.tags[Tag.TO_BE_DESTROYED] = 0
for ench in list(e.enchantments):
self.game.remove_enchantment(ench)

if position is None or position > len(dst):
dst.append(e)
else:
dst.insert(max(0, position), e)
self._reindex(dst)
e[Tag.ZONE] = to
return True

def _detach(self, e):
pid, src = e[Tag.CONTROLLER], e[Tag.ZONE]
lst = self.get(pid, src)
if e in lst:
lst.remove(e); self._reindex(lst)
e.tags[Tag.ZONE] = Zone.INVALID

@staticmethod
def _reindex(lst):
for i, x in enumerate(lst):
x.tags[Tag.ZONE_POSITION] = i + 1


# =============================================================================
# 8. 事件总线
# =============================================================================


@dataclass
class EventCtx:
kind: Event
entity: Entity | None = None
source: Entity | None = None
target: Entity | None = None
player: int | None = None
amount: int = 0
fatal: bool = False
target_health_before: int = 0
prevented: bool = False
mutable_amount: int = 0
snapshot: DeathSnapshot | None = None


MAX_TRIGGER_DEPTH = 128


class EventBus:
def __init__(self, game):
self.game = game
self.subs: dict = defaultdict(list)
self.queue: deque = deque()
self.depth = 0

def register(self, owner: Entity, td: TriggerDef):
self.subs[td.on].append((owner, td))

def register_card(self, e: Entity):
for td in e.defn.triggers:
self.register(e, td)
if e.defn.deathrattle:
self.register(e, TriggerDef(
on=Event.DEATH, action=e.defn.deathrattle, source_only=True,
zones=frozenset({Zone.PLAY, Zone.GRAVEYARD})))
pd = e.defn.progression
if pd:
self.register(e, TriggerDef(on=pd.counter_event, action=Progress(),
condition=pd.condition, zones=pd.zones))

def unregister_all(self, e: Entity):
for k in list(self.subs):
self.subs[k] = [(o, t) for (o, t) in self.subs[k] if o is not e]

def collect(self, ctx: EventCtx) -> list:
out = []
for owner, td in self.subs.get(ctx.kind, ()):
if ctx.kind != Event.DEATH and owner[Tag.ZONE] not in td.zones:
continue
if td.source_only and ctx.entity is not owner: continue
if owner.has(Tag.SILENCED): continue
if td.once and owner.has(Tag.TRIGGERED_ONCE): continue
c = Ctx(source=owner, controller=owner[Tag.CONTROLLER], event=ctx)
if td.condition and not td.condition.eval(self.game, owner, c):
continue
out.append((owner, td))
out.sort(key=lambda x: x[0][Tag.ENTITY_ID]) # ★ 铁律一
return out

def emit(self, ctx: EventCtx):
self.queue.extend((o, t, ctx) for (o, t) in self.collect(ctx))

def emit_now(self, ctx: EventCtx):
"""立即执行,不入队(用于 PROPOSED_ATTACK / PROPOSED_DEATH 等)"""
for owner, td in self.collect(ctx):
if td.once: owner[Tag.TRIGGERED_ONCE] = 1
c = Ctx(source=owner, controller=owner[Tag.CONTROLLER], event=ctx)
td.action.run(self.game, c)

def resolve(self):
self.depth += 1
if self.depth > MAX_TRIGGER_DEPTH:
self.game.force_draw("INFINITE_LOOP")
self.queue.clear(); self.depth -= 1; return
while self.queue:
owner, td, ctx = self.queue.popleft()
if td.on != Event.DEATH and owner[Tag.ZONE] not in td.zones:
continue # ★ 二次校验
if td.once:
if owner.has(Tag.TRIGGERED_ONCE): continue
owner[Tag.TRIGGERED_ONCE] = 1
c = Ctx(source=owner, controller=owner[Tag.CONTROLLER], event=ctx)
td.action.run(self.game, c)
self.depth -= 1


# =============================================================================
# 9. 光环
# =============================================================================


class AuraBus:
def __init__(self, game):
self.game = game
self.dirty = True
self.deltas: dict = {}

def mark_dirty(self): self.dirty = True

def recompute(self):
if not self.dirty: return
self.dirty = False
old, new = self.deltas, defaultdict(lambda: defaultdict(int))
srcs = []
for pid in (0, 1):
for e in self.game.zones.get(pid, Zone.PLAY):
if e.has(Tag.SILENCED): continue
for ad in e.defn.auras: srcs.append((e, ad))
srcs.sort(key=lambda x: x[0][Tag.ENTITY_ID])
for src, ad in srcs:
c = Ctx(source=src, controller=src[Tag.CONTROLLER])
if ad.condition and not ad.condition.eval(self.game, src, c):
continue
for tgt in ad.selector.eval(self.game, c):
if tgt is src and not ad.include_self: continue
new[tgt.id][ad.tag] += ad.amount
self.deltas = {k: dict(v) for k, v in new.items()}
self._apply(old, self.deltas)

def _apply(self, old, new):
for eid in set(old) | set(new):
e = self.game.entities.get(eid)
if e is None: continue
o, n = old.get(eid, {}), new.get(eid, {})
for tag in set(o) | set(n):
d = n.get(tag, 0) - o.get(tag, 0)
if d:
e.tags[tag] = e.tags.get(tag, 0) + d
self.game.record_change(e, tag, e.tags[tag] - d, e.tags[tag])


# =============================================================================
# 10. 玩家
# =============================================================================


@dataclass
class Stats:
total: dict = field(default_factory=lambda: defaultdict(int))
turn: dict = field(default_factory=lambda: defaultdict(int))
last: dict = field(default_factory=lambda: defaultdict(int))

def bump(self, key, n=1):
self.total[key] += n
self.turn[key] += n

def rotate(self):
self.last = defaultdict(int, self.turn)
self.turn = defaultdict(int)


class Player:
def __init__(self, game, pid, klass="NEUTRAL"):
self.game, self.pid, self.klass = game, pid, klass
self.tags = {Tag.MANA_CRYSTALS: 0, Tag.MANA_AVAILABLE: 0,
Tag.OVERLOAD_OWED: 0, Tag.OVERLOAD_LOCKED: 0,
Tag.FATIGUE: 0, Tag.COMBO_ACTIVE: 0}
self.hero: Entity | None = None
self.hero_power: Entity | None = None
self.weapon: Entity | None = None
self.stats = Stats()

def __getitem__(self, t): return self.tags.get(t, 0)
def __setitem__(self, t, v): self.tags[t] = v


# =============================================================================
# 11. 游戏主体
# =============================================================================


class GameOver(Exception):
def __init__(self, winner): self.winner = winner


class Game:
def __init__(self, seed=0, verbose=False, policy=None):
self.rng = GameRNG(seed)
self.verbose = verbose
self.entities: dict[int, Entity] = {}
self._next_id = itertools.count(1)
self.zones = ZoneManager(self)
self.events = EventBus(self)
self.auras = AuraBus(self)
self.players = [Player(self, 0), Player(self, 1)]
self.turn = 0
self.current = 0
self.history: list = []
self.log_lines: list[str] = []
self.over = False
self.winner = None
self.policy = policy or (lambda pid, opts, kind: opts[0])
self.is_simulation = False

# ------------------------------------------------------------ 基础设施
def player(self, pid) -> Player: return self.players[pid]

def record_change(self, e, tag, old, new):
self.history.append((e.id, int(tag), old, new))

def log(self, msg):
self.log_lines.append(msg)
if self.verbose: print(msg)

def ask_choice(self, pid, options, kind="DISCOVER"):
return self.policy(pid, options, kind)

def create_entity(self, card_id, controller, zone=Zone.SETASIDE) -> Entity:
defn = CARD_DB[card_id]
e = Entity(next(self._next_id), self, defn, controller)
self.entities[e.id] = e
self.zones.move(e, zone)
return e

def remove_entity(self, e):
self.events.unregister_all(e)
self.zones._detach(e)
self.entities.pop(e.id, None)

def all_entities_of(self, pid):
for z in Zone:
yield from self.zones.get(pid, z)

def neighbors(self, e) -> list:
board = self.zones.get(e[Tag.CONTROLLER], Zone.PLAY)
if e not in board: return []
i = board.index(e)
return [x for x in (board[i-1] if i > 0 else None,
board[i+1] if i+1 < len(board) else None) if x]

# ------------------------------------------------------------ 事件
def emit(self, kind, **kw):
self.events.emit(EventCtx(kind=kind, **kw))

def emit_now(self, kind, **kw):
ctx = EventCtx(kind=kind, **kw)
self.events.emit_now(ctx)
return ctx

# ------------------------------------------------------------ 附魔
def apply_enchantment(self, host, atk=0, hp=0, keywords=(), cost=0):
ench = Entity(next(self._next_id), self,
CARD_DB["ENCH_GENERIC"], host[Tag.CONTROLLER])
self.entities[ench.id] = ench
ench.tags[Tag.ATTACHED] = host.id
ench.tags["delta"] = {}
deltas = {}
if atk: deltas[Tag.ATK] = atk
if hp: deltas[Tag.HEALTH] = hp
if cost: deltas[Tag.COST] = cost
ench.tags["delta"] = deltas
ench.tags["kws"] = tuple(keywords)
for t, d in deltas.items():
host[t] = host[t] + d
for k in keywords:
host[KEYWORD_TO_TAG[k]] = 1
host.enchantments.append(ench)
return ench

def remove_enchantment(self, ench):
host = self.entities.get(ench.tags.get(Tag.ATTACHED))
if host is None: return
for t, d in ench.tags.get("delta", {}).items():
host[t] = host[t] - d
for k in ench.tags.get("kws", ()):
host[KEYWORD_TO_TAG[k]] = 0
if ench in host.enchantments: host.enchantments.remove(ench)
self.entities.pop(ench.id, None)

def silence(self, e):
if not e.is_minion: return
for ench in list(e.enchantments): self.remove_enchantment(ench)
self.events.unregister_all(e)
for t in SILENCEABLE_TAGS: e.tags.pop(t, None)
base = e.defn.base_tags()
e[Tag.HEALTH] = base[Tag.HEALTH]
e[Tag.ATK] = base[Tag.ATK]
e[Tag.SILENCED] = 1
self.auras.mark_dirty(); self.auras.recompute()
self.log(f" SILENCE {e}")

def transform(self, e, new_card_id):
keep = {t: e.tags[t] for t in TRANSFORM_PRESERVED if t in e.tags}
for ench in list(e.enchantments): self.remove_enchantment(ench)
e.enchantments.clear()
self.events.unregister_all(e)
e.tags.clear()
e.tags.update(CARD_DB[new_card_id].base_tags())
e.tags.update(keep)
e.progress_stage = 0
self.events.register_card(e)
self.auras.mark_dirty()

# ------------------------------------------------------------ 数值
def spellpower(self, pid) -> int:
return sum(e[Tag.SPELLPOWER] for e in self.zones.get(pid, Zone.PLAY))

def compute_cost(self, card) -> int:
base = card[Tag.COST] - card[Tag.PREPARE_DISCOUNT]
return max(0, base)

# ------------------------------------------------------------ 伤害/治疗
def damage(self, target, amount, source=None) -> int:
if amount <= 0 or not in_play(target): return 0
if target.has(Tag.IMMUNE): return 0

ctx = self.emit_now(Event.PRE_DAMAGE, target=target, source=source,
amount=amount, mutable_amount=amount)
amount = max(0, ctx.mutable_amount)
if amount == 0: return 0

if target.has(Tag.DIVINE_SHIELD):
target[Tag.DIVINE_SHIELD] = 0
target.ds_absorbed = True
self.log(f" DIVINE SHIELD absorbs on {target.defn.name}")
return 0

before = target.current_health
if target.is_hero and target[Tag.ARMOR] > 0:
absorbed = min(target[Tag.ARMOR], amount)
target[Tag.ARMOR] -= absorbed
amount -= absorbed
if amount == 0: return 0

target[Tag.DAMAGE] += amount
fatal = target.current_health <= 0
self.log(f" DAMAGE {amount} -> {target.defn.name} "
f"({target.current_health} left)")
self.emit(Event.DAMAGE, entity=target, target=target, source=source,
amount=amount, fatal=fatal, target_health_before=before)

if source is not None and source.has(Tag.LIFESTEAL):
self.heal(self.player(source[Tag.CONTROLLER]).hero, amount)

# 剧毒
if (source is not None and source.has(Tag.POISONOUS)
and target.is_minion and amount > 0):
target[Tag.TO_BE_DESTROYED] = 1
return amount

def heal(self, target, amount) -> int:
if amount <= 0: return 0
healed = min(amount, target[Tag.DAMAGE])
if healed:
target[Tag.DAMAGE] -= healed
self.emit(Event.HEAL, entity=target, target=target, amount=healed)
return healed

# ------------------------------------------------------------ 召唤
def summon(self, card_id, controller, position=None) -> Entity | None:
e = self.create_entity(card_id, controller, Zone.SETASIDE)
self.emit_now(Event.PRE_SUMMON, entity=e)
if not self.zones.move(e, Zone.PLAY, position):
self.log(f" SUMMON FAILED (board full) {CARD_DB[card_id].name}")
self.entities.pop(e.id, None)
return None
e[Tag.EXHAUSTED] = 0 if e.has(Tag.CHARGE) else 1
self.events.register_card(e)
self.auras.mark_dirty(); self.auras.recompute() # ★ 光环先于事件
self.log(f" SUMMON {e.defn.name} (pos {e[Tag.ZONE_POSITION]})")
# 巨型
for part in e.defn.colossal_parts:
self.summon(part, controller, e[Tag.ZONE_POSITION])
self.emit(Event.SUMMON, entity=e)
return e

def add_to_hand(self, pid, card_id) -> Entity | None:
e = self.create_entity(card_id, pid, Zone.SETASIDE)
if not self.zones.move(e, Zone.HAND):
return None
e[Tag.DRAWN_ON_TURN] = self.turn
return e

# ------------------------------------------------------------ 抽牌
def draw(self, pid, n=1):
for _ in range(n):
deck = self.zones.get(pid, Zone.DECK)
if not deck:
p = self.player(pid)
p[Tag.FATIGUE] += 1
self.log(f" FATIGUE {p[Tag.FATIGUE]} on P{pid}")
self.damage(p.hero, p[Tag.FATIGUE], source=None)
continue
card = deck.pop()
self.zones._reindex(deck)
hand = self.zones.get(pid, Zone.HAND)
if len(hand) >= ZoneManager.CAP[Zone.HAND]:
self.zones.move(card, Zone.GRAVEYARD)
self.log(f" BURN {card.defn.name}")
self.emit(Event.CARD_BURNED, entity=card, player=pid)
else:
self.zones.move(card, Zone.HAND)
card[Tag.DRAWN_ON_TURN] = self.turn
self.emit(Event.DRAW, entity=card, player=pid)

# ------------------------------------------------------------ 出牌
def legal_targets(self, card) -> list:
if card.defn.targeting is None: return []
c = Ctx(source=card, controller=card[Tag.CONTROLLER])
out = []
for t in card.defn.targeting.eval(self, c):
if t.has(Tag.ELUSIVE) or t.has(Tag.IMMUNE): continue
if t.has(Tag.STEALTH) and t[Tag.CONTROLLER] != card[Tag.CONTROLLER]:
continue
out.append(t)
return out

def play_card(self, card, target=None, position=None, choose=0):
pid = card[Tag.CONTROLLER]
p = self.player(pid)
cost = self.compute_cost(card)
if cost > p[Tag.MANA_AVAILABLE]:
raise ValueError(f"not enough mana for {card.defn.name}")
if card[Tag.LOCKED_UNTIL_TURN] > self.turn:
raise ValueError("card is prepared and locked this turn")

p[Tag.MANA_AVAILABLE] -= cost
combo = bool(p[Tag.COMBO_ACTIVE])
finale = (p[Tag.MANA_AVAILABLE] == 0) # ★ 终曲快照
self.log(f"P{pid} PLAY {card.defn.name} (cost {cost})"
+ (f" -> {target.defn.name}" if target else ""))

self.zones.move(card, Zone.SETASIDE)
self.emit(Event.PLAY_CARD, entity=card, player=pid, target=target)
p.stats.bump("cards_played")
if card[Tag.CARDRACE]: p.stats.bump(f"race_{card[Tag.CARDRACE]}")
if card[Tag.SPELL_SCHOOL]: p.stats.bump(f"school_{card[Tag.SPELL_SCHOOL]}")

ctype = card[Tag.CARDTYPE]
ctx = Ctx(source=card, controller=pid, target=target,
choose=choose, finale=finale)

if ctype == CardType.MINION:
if self.zones.move(card, Zone.PLAY, position):
card[Tag.EXHAUSTED] = 0 if card.has(Tag.CHARGE) else 1
self.events.register_card(card)
self.auras.mark_dirty(); self.auras.recompute()
for part in card.defn.colossal_parts:
self.summon(part, pid, card[Tag.ZONE_POSITION])
self.emit(Event.SUMMON, entity=card)
self.emit(Event.MINION_PLAYED, entity=card, player=pid)
script = card.defn.combo if (combo and card.defn.combo) \
else card.defn.battlecry
if script:
script.run(self, ctx)
elif ctype == CardType.SPELL:
self.emit(Event.SPELL_CAST, entity=card, player=pid, target=target)
if card.defn.progression: # 任务
self.zones.move(card, Zone.SECRET)
card[Tag.QUEST_TOTAL] = card.defn.progression.thresholds[0]
self.events.register_card(card)
elif "SECRET" in card.defn.keywords:
self.zones.move(card, Zone.SECRET)
self.events.register_card(card)
else:
if card.defn.spell: card.defn.spell.run(self, ctx)
self.zones.move(card, Zone.GRAVEYARD)
self.emit(Event.AFTER_SPELL, entity=card, player=pid)

p[Tag.COMBO_ACTIVE] = 1
self.events.resolve()
self.process_deaths()

def trade_card(self, card) -> bool:
pid = card[Tag.CONTROLLER]
p = self.player(pid)
if not card.has(Tag.TRADEABLE) or p[Tag.MANA_AVAILABLE] < 1:
return False
p[Tag.MANA_AVAILABLE] -= 1
self.zones.move(card, Zone.DECK)
deck = self.zones.get(pid, Zone.DECK)
self.rng.shuffle(deck); self.zones._reindex(deck)
self.log(f"P{pid} TRADE {card.defn.name}")
self.emit(Event.CARD_TRADED, entity=card, player=pid)
self.draw(pid, 1)
self.events.resolve()
return True

def prepare_card(self, card, mana) -> bool:
pid = card[Tag.CONTROLLER]
p = self.player(pid)
if not card.has(Tag.PREPARE) or card.has(Tag.PREPARED): return False
cur = self.compute_cost(card)
mana = min(mana, p[Tag.MANA_AVAILABLE], max(0, cur - 1))
if mana < 1: return False
p[Tag.MANA_AVAILABLE] -= mana
card[Tag.PREPARE_DISCOUNT] = mana + 1
card[Tag.PREPARED] = 1
card[Tag.LOCKED_UNTIL_TURN] = self.turn + 1
self.log(f"P{pid} PREPARE {card.defn.name} (-{mana + 1})")
self.emit(Event.CARD_PREPARED, entity=card, player=pid, amount=mana)
self.events.resolve()
return True

# ------------------------------------------------------------ 攻击
def can_attack(self, e) -> bool:
if not in_play(e): return False
if e.has(Tag.FROZEN): return False
if e[Tag.ATK] <= 0: return False
max_atk = 2 if e.has(Tag.WINDFURY) else 1
if e[Tag.NUM_ATTACKS] >= max_atk: return False
if e[Tag.EXHAUSTED] and not (e.has(Tag.CHARGE) or e.has(Tag.RUSH)):
return False
return True

def legal_attack_targets(self, attacker) -> list:
opp = 1 - attacker[Tag.CONTROLLER]
cands = list(self.zones.get(opp, Zone.PLAY)) + [self.player(opp).hero]
cands = [c for c in cands
if not c.has(Tag.STEALTH) and not c.has(Tag.IMMUNE)]
if (attacker.has(Tag.RUSH) and not attacker.has(Tag.CHARGE)
and attacker[Tag.NUM_TURNS_IN_PLAY] == 0):
cands = [c for c in cands if not c.is_hero]
taunts = [c for c in cands if c.has(Tag.TAUNT)]
return taunts if taunts else cands

def attack(self, attacker, defender):
if not self.can_attack(attacker):
raise ValueError(f"{attacker} cannot attack")
if defender not in self.legal_attack_targets(attacker):
raise ValueError(f"illegal target {defender}")

ctx = self.emit_now(Event.PROPOSED_ATTACK, source=attacker, target=defender)
defender = ctx.target
if ctx.prevented: return
if attacker.has(Tag.STEALTH): attacker[Tag.STEALTH] = 0

self.log(f"P{attacker[Tag.CONTROLLER]} ATTACK "
f"{attacker.defn.name} -> {defender.defn.name}")
self.emit(Event.ATTACK, source=attacker, target=defender)
self.events.resolve()
if not in_play(attacker) or attacker.current_health <= 0:
self.process_deaths(); return
if not in_play(defender) or defender.current_health <= 0:
self.process_deaths(); return

atk_p, def_p = attacker[Tag.ATK], defender[Tag.ATK] # ★ 快照
attacker.ds_absorbed = defender.ds_absorbed = False
if def_p > 0: self.damage(attacker, def_p, source=defender)
if atk_p > 0: self.damage(defender, atk_p, source=attacker)

attacker[Tag.NUM_ATTACKS] += 1
self.emit(Event.AFTER_ATTACK, source=attacker, target=defender)
self.events.resolve()
self.process_deaths()

# ------------------------------------------------------------ 死亡结算
def process_deaths(self):
rounds = 0
while True:
rounds += 1
if rounds > 64:
self.force_draw("DEATH_LOOP"); return
dying = []
for pid in (0, 1):
for e in self.zones.get(pid, Zone.PLAY):
if e.is_minion and (e.current_health <= 0
or e.has(Tag.TO_BE_DESTROYED)):
dying.append(e)
h = self.player(pid).hero
if h and h.current_health <= 0:
self.over = True
if self.over:
self._finish(); return
if not dying: return

dying.sort(key=lambda e: e[Tag.ENTITY_ID]) # ★ 铁律一
for e in dying:
e.death_snapshot = DeathSnapshot(
e.id, e[Tag.CARD_ID], e[Tag.CONTROLLER],
e[Tag.ZONE_POSITION], e[Tag.ATK], e[Tag.HEALTH],
e[Tag.CARDRACE])
e.death_prevented = False
for e in dying:
self.emit_now(Event.PROPOSED_DEATH, entity=e)
dying = [e for e in dying if not e.death_prevented]

snaps = {e.id: e.death_snapshot for e in dying}
for e in dying: # ★ 先全部入墓
self.log(f" DEATH {e.defn.name}")
self.zones.move(e, Zone.GRAVEYARD)
self.auras.mark_dirty(); self.auras.recompute()

for e in dying: # ★ 再触发亡语
self.emit(Event.DEATH, entity=e, snapshot=snaps[e.id],
player=e[Tag.CONTROLLER])
self.events.resolve()

for e in dying: # ★ 复生在亡语后
if e.has(Tag.REBORN) and not e.reborn_used:
e.reborn_used = True
s = snaps[e.id]
n = self.summon(s.card_id, s.controller, s.position - 1)
if n:
n[Tag.HEALTH] = 1
n[Tag.REBORN] = 0

for e in dying:
self.emit(Event.AFTER_DEATH, entity=e, snapshot=snaps[e.id])
self.events.resolve()

def force_draw(self, reason):
self.log(f"!! FORCED DRAW: {reason}")
self.over = True; self.winner = None
raise GameOver(None)

def _finish(self):
h0 = self.player(0).hero.current_health
h1 = self.player(1).hero.current_health
if h0 <= 0 and h1 <= 0: self.winner = None
elif h0 <= 0: self.winner = 1
else: self.winner = 0
self.log(f"== GAME OVER, winner = {self.winner}")
raise GameOver(self.winner)

# ------------------------------------------------------------ 回合
def setup(self, deck0: list[str], deck1: list[str],
hero_ids=("HERO_MAGE", "HERO_MAGE")):
for pid, (deck, hid) in enumerate(zip((deck0, deck1), hero_ids)):
p = self.player(pid)
p.hero = self.create_entity(hid, pid, Zone.HERO)
p.hero.tags[Tag.HEALTH] = 30
cards = [self.create_entity(cid, pid, Zone.DECK) for cid in deck]
lst = self.zones.get(pid, Zone.DECK)
self.rng.shuffle(lst); self.zones._reindex(lst)
self.draw(0, 3); self.draw(1, 4)
self.emit(Event.GAME_START); self.events.resolve()

def begin_turn(self):
p = self.player(self.current)
self.turn += 1
p[Tag.OVERLOAD_LOCKED] = p[Tag.OVERLOAD_OWED]
p[Tag.OVERLOAD_OWED] = 0
p[Tag.MANA_CRYSTALS] = min(10, p[Tag.MANA_CRYSTALS] + 1)
p[Tag.MANA_AVAILABLE] = p[Tag.MANA_CRYSTALS] - p[Tag.OVERLOAD_LOCKED]
p[Tag.COMBO_ACTIVE] = 0
for e in self.zones.get(self.current, Zone.PLAY):
e[Tag.EXHAUSTED] = 0
if e[Tag.NUM_ATTACKS] == 0 and e.has(Tag.FROZEN):
e[Tag.FROZEN] = 0
e[Tag.NUM_ATTACKS] = 0
e[Tag.NUM_TURNS_IN_PLAY] += 1
p.hero[Tag.NUM_ATTACKS] = 0
self.log(f"--- TURN {self.turn} (P{self.current}, "
f"{p[Tag.MANA_AVAILABLE]}/{p[Tag.MANA_CRYSTALS]} mana)")
self.emit(Event.TURN_START, player=self.current)
self.events.resolve(); self.process_deaths()
self.draw(self.current, 1)
self.events.resolve(); self.process_deaths()

def end_turn(self):
self.emit(Event.TURN_END, player=self.current)
self.events.resolve(); self.process_deaths()
for e in list(self.zones.get(self.current, Zone.HAND)):
if e.has(Tag.TEMPORARY) or e.is_echo_copy:
self.zones.move(e, Zone.GRAVEYARD)
self.player(self.current).stats.rotate()
self.current = 1 - self.current
self.begin_turn()


# =============================================================================
# 12. 卡牌数据
# =============================================================================


register(CardDef("ENCH_GENERIC", "附魔", card_type=CardType.ENCHANTMENT))
register(CardDef("HERO_MAGE", "吉安娜", card_type=CardType.HERO, health=30))

# ---- 基础随从 ----
register(CardDef("WISP", "小精灵", cost=0, attack=1, health=1))
register(CardDef("RIVER_CROC", "淡水鳄", cost=2, attack=2, health=3, race="BEAST"))
register(CardDef("MURLOC", "鱼人猎潮者", cost=2, attack=2, health=1, race="MURLOC"))
register(CardDef("BOULDERFIST", "石拳食人魔", cost=6, attack=6, health=7))
register(CardDef("SEN_JIN", "森金持盾卫士", cost=4, attack=3, health=5,
keywords=frozenset({"TAUNT"})))
register(CardDef("ARGENT_SQUIRE", "银色侍从", cost=1, attack=1, health=1,
keywords=frozenset({"DIVINE_SHIELD"})))
register(CardDef("WOLFRIDER", "狼骑兵", cost=3, attack=3, health=1,
keywords=frozenset({"CHARGE"})))
register(CardDef("SPIDER_TANK", "蜘蛛坦克", cost=3, attack=3, health=4,
race="MECHANICAL"))
register(CardDef("EMPEROR_COBRA", "帝王眼镜蛇", cost=3, attack=2, health=3,
race="BEAST", keywords=frozenset({"POISONOUS"})))
register(CardDef("BIG_GUY", "巨兽", cost=8, attack=9, health=9))

# ---- 亡语 ----
register(CardDef("LOOT_HOARDER", "藏宝妖精", cost=2, attack=2, health=1,
deathrattle=Draw(1), text="亡语:抽一张牌"))
register(CardDef("IMP_TOKEN", "小鬼", cost=1, attack=1, health=1, race="DEMON"))
register(CardDef("IMP_MASTER", "小鬼军官", cost=3, attack=1, health=5,
deathrattle=Summon("IMP_TOKEN", at_death_position=True)))
register(CardDef("UNSTABLE_GHOUL", "不稳定的食尸鬼", cost=2, attack=1, health=3,
keywords=frozenset({"TAUNT"}),
deathrattle=Damage(Sel.ALL_MINIONS, 1),
text="嘲讽。亡语:对所有随从造成 1 点伤害"))

# ---- 光环 ----
register(CardDef("STORMWIND_CHAMPION", "暴风城勇士", cost=7, attack=6, health=6,
auras=(AuraDef(Sel.FRIENDLY_MINIONS, Tag.ATK, 1),
AuraDef(Sel.FRIENDLY_MINIONS, Tag.HEALTH, 1)),
text="你的其他随从获得 +1/+1"))
register(CardDef("SUNFURY", "破碎残阳祭司", cost=3, attack=3, health=2,
battlecry=GiveKeyword(Sel.TARGET, "TAUNT"),
targeting=Sel.FRIENDLY_MINIONS))
register(CardDef("KOBOLD", "狗头人地卜师", cost=3, attack=3, health=2,
spellpower=1, text="法术伤害 +1"))


# ---- 触发型 ----
register(CardDef("KNIFE_JUGGLER", "飞刀杂耍者", cost=2, attack=3, health=2,
triggers=(TriggerDef(on=Event.SUMMON,
condition=Cond.OWNER,
action=Damage(Sel.ENEMY_CHARACTERS.random(1), 1)),),
text="每当你召唤一个随从,对随机敌人造成 1 点伤害"))
register(CardDef("FRENZY_BOAR", "狂乱野猪", cost=3, attack=3, health=4,
race="BEAST",
triggers=(TriggerDef(on=Event.DAMAGE,
condition=Cond.SELF_IS_EVENT_TARGET & Cond.SURVIVED,
action=Buff(Sel.SELF, atk=2), once=True),),
text="狂乱:获得 +2 攻击力"))
register(CardDef("OVERKILL_BEAST", "超杀猛兽", cost=4, attack=4, health=4,
race="BEAST",
triggers=(TriggerDef(on=Event.DAMAGE, condition=Cond.OVERKILL,
action=Draw(1)),),
text="超杀:抽一张牌"))
register(CardDef("SPELLBURST_MAGE", "法术迸发学徒", cost=3, attack=2, health=4,
triggers=(TriggerDef(on=Event.AFTER_SPELL, condition=Cond.OWNER,
action=Buff(Sel.SELF, atk=2, hp=2), once=True),),
text="法术迸发:获得 +2/+2"))
register(CardDef("RAGNAROS", "炎魔之王", cost=8, attack=8, health=8,
keywords=frozenset(),
triggers=(TriggerDef(on=Event.TURN_END, condition=Cond.MY_TURN,
action=Damage(Sel.ENEMY_CHARACTERS.random(1), 8)),),
text="你的回合结束时,对随机敌人造成 8 点伤害"))

# ---- 关键词展示 ----
register(CardDef("REBORN_GUY", "复生守卫", cost=4, attack=4, health=3,
keywords=frozenset({"REBORN", "TAUNT"})))
register(CardDef("RUSH_WOLF", "突袭之狼", cost=3, attack=3, health=3,
race="BEAST", keywords=frozenset({"RUSH"})))
register(CardDef("LIFESTEAL_DEMON", "吸血恶魔", cost=5, attack=5, health=5,
race="DEMON", keywords=frozenset({"LIFESTEAL"})))
register(CardDef("COLOSSAL_KRAKEN", "巨型海妖", cost=7, attack=5, health=5,
colossal_parts=("KRAKEN_TENTACLE", "KRAKEN_TENTACLE"),
text="巨型 +2"))
register(CardDef("KRAKEN_TENTACLE", "海妖触手", cost=1, attack=2, health=2,
keywords=frozenset({"TAUNT"})))
register(CardDef("TRADEABLE_GUY", "可交易的佣兵", cost=4, attack=4, health=4,
keywords=frozenset({"TRADEABLE"})))
register(CardDef("PREPARE_GIANT", "可准备的巨人", cost=8, attack=8, health=8,
keywords=frozenset({"PREPARE"})))

# ---- 法术 ----
register(CardDef("FIREBALL", "火球术", cost=4, card_type=CardType.SPELL,
spell_school="FIRE", targeting=Sel.ALL_CHARACTERS,
spell=Damage(Sel.TARGET, 6)))
register(CardDef("FROSTBOLT", "寒冰箭", cost=2, card_type=CardType.SPELL,
spell_school="FROST", targeting=Sel.ALL_CHARACTERS,
spell=Seq(Damage(Sel.TARGET, 3), Freeze(Sel.TARGET))))
register(CardDef("BLIZZARD", "暴风雪", cost=6, card_type=CardType.SPELL,
spell_school="FROST",
spell=Seq(Damage(Sel.ENEMY_MINIONS, 2), Freeze(Sel.ENEMY_MINIONS))))
register(CardDef("ARCANE_INT", "奥术智慧", cost=3, card_type=CardType.SPELL,
spell_school="ARCANE", spell=Draw(2)))
register(CardDef("SILENCE_SPELL", "沉默", cost=0, card_type=CardType.SPELL,
targeting=Sel.ALL_MINIONS, spell=SilenceAct(Sel.TARGET)))
register(CardDef("SHIELD_SPELL", "盾牌格挡", cost=3, card_type=CardType.SPELL,
spell=Seq(GainArmor(5), Draw(1))))
register(CardDef("LIGHTNING", "闪电箭", cost=1, card_type=CardType.SPELL,
spell_school="NATURE", targeting=Sel.ALL_CHARACTERS,
spell=Seq(Damage(Sel.TARGET, 3), Overload(1))))
register(CardDef("DISCOVER_SPELL", "发现学识", cost=2, card_type=CardType.SPELL,
spell=Discover(["FIREBALL", "FROSTBOLT", "ARCANE_INT",
"BLIZZARD", "LIGHTNING"])))

# ---- 奥秘 ----
register(CardDef("MIRROR_ENTITY", "镜像实体", cost=3, card_type=CardType.SPELL,
keywords=frozenset({"SECRET"}),
triggers=(TriggerDef(
on=Event.MINION_PLAYED, zones=frozenset({Zone.SECRET}),
condition=~Cond.OWNER,
action=Seq(RevealSecret(),
SpecialEffect("MIRROR_COPY"))),)))


@special("MIRROR_COPY")
def _mirror(g, c):
ev = c.event
if ev and ev.entity:
g.summon(ev.entity[Tag.CARD_ID], c.controller)


# ---- 任务(状态机范式) ----
register(CardDef("QUEST_SPELLS", "开启传送门", cost=1, card_type=CardType.SPELL,
keywords=frozenset({"QUEST"}),
progression=ProgressionDef(
counter_event=Event.SPELL_CAST,
thresholds=(3,),
condition=Cond.OWNER,
zones=frozenset({Zone.SECRET}),
complete_action=AddToHand("BIG_GUY")),
text="任务:使用 3 个法术。奖励:获得一个巨兽"))


# =============================================================================
# 13. 自检 / 演示
# =============================================================================


def make_test_game(seed=1, verbose=False, policy=None) -> Game:
g = Game(seed=seed, verbose=verbose, policy=policy)
deck = ["WISP"] * 15
g.setup(list(deck), list(deck))
g.player(0)[Tag.MANA_CRYSTALS] = 10
g.player(0)[Tag.MANA_AVAILABLE] = 10
g.player(1)[Tag.MANA_CRYSTALS] = 10
g.player(1)[Tag.MANA_AVAILABLE] = 10
return g


def spawn(g, card_id, pid, **tags) -> Entity:
e = g.summon(card_id, pid)
for k, v in tags.items():
e[k] = v
return e


def check_invariants(g: Game):
seen = set()
for pid in (0, 1):
board = g.zones.get(pid, Zone.PLAY)
assert len(board) <= 7, "board over 7"
assert [e[Tag.ZONE_POSITION] for e in board] == list(range(1, len(board) + 1)), \
f"positions not contiguous: {[e[Tag.ZONE_POSITION] for e in board]}"
assert len(g.zones.get(pid, Zone.HAND)) <= 10, "hand over 10"
for m in board:
if m.is_minion:
assert m.current_health > 0, f"dead minion alive on board: {m}"
p = g.player(pid)
assert 0 <= p[Tag.MANA_AVAILABLE] <= 10
for z in Zone:
for e in g.zones.get(pid, z):
assert e.id not in seen, f"entity {e.id} in two zones"
seen.add(e.id)
assert e[Tag.ZONE] == z


# --------------------------------------------------------------------- 测试
def t_两个食尸鬼同时死亡():
g = make_test_game()
a = spawn(g, "UNSTABLE_GHOUL", 0); a[Tag.DAMAGE] = 2
b = spawn(g, "UNSTABLE_GHOUL", 0); b[Tag.DAMAGE] = 2
c = spawn(g, "SPIDER_TANK", 1) # 3/4
for m in (a, b):
g.damage(m, 2, source=None)
g.process_deaths()
assert a[Tag.ZONE] == Zone.GRAVEYARD and b[Tag.ZONE] == Zone.GRAVEYARD
# 两个亡语都触发 → 蜘蛛坦克受 2 点伤害
assert c[Tag.DAMAGE] == 2, c[Tag.DAMAGE]
check_invariants(g)
return "两个亡语都触发,蜘蛛坦克受 2 点伤害"


def t_光环沉默致死():
g = make_test_game()
champ = spawn(g, "STORMWIND_CHAMPION", 0)
sun = spawn(g, "SUNFURY", 0) # 基础 3/2,光环后 4/3
assert sun[Tag.HEALTH] == 3, sun[Tag.HEALTH]
g.damage(sun, 2, source=None)
assert sun.current_health == 1
g.silence(champ)
g.process_deaths()
assert sun[Tag.ZONE] == Zone.GRAVEYARD, "光环消失后应死亡"
check_invariants(g)
return "沉默光环源后,受伤的随从因上限下降而死亡"


def t_圣盾挡剧毒():
g = make_test_game()
cobra = spawn(g, "EMPEROR_COBRA", 0) # 2/3 剧毒
cobra[Tag.EXHAUSTED] = 0
squire = spawn(g, "ARGENT_SQUIRE", 1) # 1/1 圣盾
g.attack(cobra, squire)
assert squire[Tag.ZONE] == Zone.PLAY, "圣盾应挡住剧毒"
assert not squire.has(Tag.DIVINE_SHIELD)
check_invariants(g)
return "圣盾吸收伤害 → 剧毒不生效,随从存活"


def t_亡语召唤位置():
g = make_test_game()
left = spawn(g, "WISP", 0)
master = spawn(g, "IMP_MASTER", 0) # 位置 2
right = spawn(g, "WISP", 0)
assert master[Tag.ZONE_POSITION] == 2
g.damage(master, 99, source=None)
g.process_deaths()
board = g.zones.get(0, Zone.PLAY)
assert board[1].defn.name == "小鬼", [b.defn.name for b in board]
check_invariants(g)
return "亡语衍生物在原随从位置召唤(快照生效)"


def t_触发顺序按上场时间():
g = make_test_game()
order = []

@special("RECORD_A")
def _a(gg, cc): order.append("A")

@special("RECORD_B")
def _b(gg, cc): order.append("B")

register(CardDef("REC_A", "记录A", cost=1, attack=1, health=1,
triggers=(TriggerDef(on=Event.TURN_END,
action=SpecialEffect("RECORD_A")),)))
register(CardDef("REC_B", "记录B", cost=1, attack=1, health=1,
triggers=(TriggerDef(on=Event.TURN_END,
action=SpecialEffect("RECORD_B")),)))
b = spawn(g, "REC_B", 0) # 先上场
a = spawn(g, "REC_A", 0, )
# 把 A 放到最左(位置 1)
board = g.zones.get(0, Zone.PLAY)
board.remove(a); board.insert(0, a); g.zones._reindex(board)
assert a[Tag.ZONE_POSITION] == 1 and b[Tag.ZONE_POSITION] == 2
g.emit(Event.TURN_END, player=0)
g.events.resolve()
assert order == ["B", "A"], order # 按 ENTITY_ID,不是位置
return "触发顺序按上场时间(ENTITY_ID),不是战场位置"


def t_复生在亡语之后():
g = make_test_game()
r = spawn(g, "REBORN_GUY", 0)
g.damage(r, 99, source=None)
g.process_deaths()
board = g.zones.get(0, Zone.PLAY)
assert len(board) == 1 and board[0][Tag.HEALTH] == 1
assert not board[0].has(Tag.REBORN), "复生体不能再次复生"
check_invariants(g)
return "复生体以 1 血回归且失去复生标签"


def t_巨型召唤部件():
g = make_test_game()
k = spawn(g, "COLOSSAL_KRAKEN", 0)
board = g.zones.get(0, Zone.PLAY)
assert len(board) == 3, [b.defn.name for b in board]
assert sum(1 for b in board if b.defn.name == "海妖触手") == 2
check_invariants(g)
return "巨型主体带出 2 个触手部件"


def t_战场已满召唤失败():
g = make_test_game()
for _ in range(7):
spawn(g, "WISP", 0)
before = len(g.zones.get(0, Zone.PLAY))
r = g.summon("WISP", 0)
assert r is None and len(g.zones.get(0, Zone.PLAY)) == before
assert len(g.zones.get(0, Zone.GRAVEYARD)) == 0, "召唤失败不进墓地"
check_invariants(g)
return "战场满时召唤失败:实体消失,不入墓地,不触发亡语"


def t_突袭不能打脸():
g = make_test_game()
w = spawn(g, "RUSH_WOLF", 0)
tgts = g.legal_attack_targets(w)
assert all(not t.is_hero for t in tgts), "突袭首回合不能打脸"
spawn(g, "WISP", 1)
tgts = g.legal_attack_targets(w)
assert len(tgts) == 1 and tgts[0].defn.name == "小精灵"
return "突袭随从首回合只能攻击随从"


def t_嘲讽限制目标():
g = make_test_game()
a = spawn(g, "WOLFRIDER", 0) # 冲锋
spawn(g, "WISP", 1)
spawn(g, "SEN_JIN", 1) # 嘲讽
tgts = g.legal_attack_targets(a)
assert len(tgts) == 1 and tgts[0].defn.name == "森金持盾卫士"
return "有嘲讽时合法目标只剩嘲讽随从"


def t_法术伤害加成():
g = make_test_game()
# 手工构造带法术伤害的随从
m = spawn(g, "KOBOLD", 0) # 3/2,法术伤害 +1
victim = spawn(g, "BOULDERFIST", 1) # 6/7
fb = g.add_to_hand(0, "FIREBALL")
g.play_card(fb, target=victim)
assert victim[Tag.ZONE] == Zone.GRAVEYARD, "6+1=7 应致死 6/7"
check_invariants(g)
return "法术伤害 +1 让火球术造成 7 点伤害"


def t_狂乱一次性():
g = make_test_game()
boar = spawn(g, "FRENZY_BOAR", 0) # 3/4
g.damage(boar, 1, source=None); g.events.resolve()
assert boar[Tag.ATK] == 5, boar[Tag.ATK]
g.damage(boar, 1, source=None); g.events.resolve()
assert boar[Tag.ATK] == 5, "狂乱只触发一次"
check_invariants(g)
return "狂乱只触发一次,且致死伤害不触发"


def t_超杀():
g = make_test_game()
beast = spawn(g, "OVERKILL_BEAST", 0) # 4/4
beast[Tag.EXHAUSTED] = 0
weak = spawn(g, "WISP", 1) # 1/1
hand_before = len(g.zones.get(0, Zone.HAND))
g.attack(beast, weak)
assert len(g.zones.get(0, Zone.HAND)) == hand_before + 1, "超杀应抽牌"
check_invariants(g)
return "4 攻打 1 血随从触发超杀"


def t_法术迸发():
g = make_test_game()
m = spawn(g, "SPELLBURST_MAGE", 0) # 2/4
sp = g.add_to_hand(0, "ARCANE_INT")
g.play_card(sp)
assert (m[Tag.ATK], m[Tag.HEALTH]) == (4, 6), (m[Tag.ATK], m[Tag.HEALTH])
sp2 = g.add_to_hand(0, "ARCANE_INT")
g.play_card(sp2)
assert m[Tag.ATK] == 4, "法术迸发只触发一次"
check_invariants(g)
return "法术迸发触发一次后消耗"


def t_任务完成():
g = make_test_game()
q = g.add_to_hand(0, "QUEST_SPELLS")
g.play_card(q)
assert q[Tag.ZONE] == Zone.SECRET
for _ in range(3):
s = g.add_to_hand(0, "ARCANE_INT")
g.play_card(s)
names = [c.defn.name for c in g.zones.get(0, Zone.HAND)]
assert "巨兽" in names, names
assert q[Tag.ZONE] == Zone.GRAVEYARD
check_invariants(g)
return "任务:使用 3 个法术后获得奖励"


def t_奥秘镜像实体():
g = make_test_game()
sec = g.add_to_hand(1, "MIRROR_ENTITY")
g.player(1)[Tag.MANA_AVAILABLE] = 10
g.play_card(sec)
assert sec[Tag.ZONE] == Zone.SECRET
m = g.add_to_hand(0, "BOULDERFIST")
g.play_card(m)
p1_board = [x.defn.name for x in g.zones.get(1, Zone.PLAY)]
assert "石拳食人魔" in p1_board, p1_board
assert sec[Tag.ZONE] == Zone.GRAVEYARD
check_invariants(g)
return "镜像实体复制对手打出的随从"


def t_过载():
g = make_test_game()
c = g.add_to_hand(0, "LIGHTNING")
g.play_card(c, target=g.player(1).hero)
assert g.player(0)[Tag.OVERLOAD_OWED] == 1
g.current = 0
g.player(0)[Tag.MANA_CRYSTALS] = 4
g.begin_turn()
p = g.player(0)
assert p[Tag.OVERLOAD_LOCKED] == 1 and p[Tag.MANA_AVAILABLE] == 4, \
(p[Tag.OVERLOAD_LOCKED], p[Tag.MANA_AVAILABLE], p[Tag.MANA_CRYSTALS])
return "过载在下回合锁定水晶"


def t_可交易():
g = make_test_game()
c = g.add_to_hand(0, "TRADEABLE_GUY")
n_hand = len(g.zones.get(0, Zone.HAND))
mana = g.player(0)[Tag.MANA_AVAILABLE]
ok = g.trade_card(c)
assert ok
assert g.player(0)[Tag.MANA_AVAILABLE] == mana - 1
assert len(g.zones.get(0, Zone.HAND)) == n_hand # 交易一张、抽一张
assert c[Tag.ZONE] == Zone.DECK
check_invariants(g)
return "可交易:花 1 费洗回牌库并抽一张"


def t_准备():
g = make_test_game()
c = g.add_to_hand(0, "PREPARE_GIANT") # 8 费
ok = g.prepare_card(c, 4)
assert ok
assert c[Tag.PREPARE_DISCOUNT] == 5 # 4 + 1
assert g.compute_cost(c) == 3
assert g.player(0)[Tag.MANA_AVAILABLE] == 6
try:
g.play_card(c)
raise AssertionError("准备后本回合不能打出")
except ValueError:
pass
g.current = 0
g.begin_turn()
g.player(0)[Tag.MANA_AVAILABLE] = 10
g.play_card(c)
assert c[Tag.ZONE] == Zone.PLAY
check_invariants(g)
return "准备:花 4 费换 5 点折扣,本回合锁定,下回合可出"


def t_发现():
picked = {}

def policy(pid, opts, kind):
picked["opts"] = list(opts)
return opts[-1]

g = make_test_game(policy=policy)
c = g.add_to_hand(0, "DISCOVER_SPELL")
g.play_card(c)
assert len(picked["opts"]) == 3
names = [x.defn.name if isinstance(x, Entity) else CARD_DB[x].name
for x in g.zones.get(0, Zone.HAND)]
assert CARD_DB[picked["opts"][-1]].name in names, names
check_invariants(g)
return f"发现三选一:{[CARD_DB[o].name for o in picked['opts']]}"


def t_疲劳():
g = make_test_game()
g.zones.get(0, Zone.DECK).clear()
hp = g.player(0).hero.current_health
g.draw(0, 3)
assert g.player(0)[Tag.FATIGUE] == 3
assert g.player(0).hero.current_health == hp - (1 + 2 + 3)
return "疲劳伤害递增 1,2,3"


def t_变形保留位置():
g = make_test_game()
spawn(g, "WISP", 0)
m = spawn(g, "BOULDERFIST", 0) # 6/7 位置 2
g.damage(m, 3, source=None)
eid, pos = m.id, m[Tag.ZONE_POSITION]
g.transform(m, "WISP")
assert m.id == eid and m[Tag.ZONE_POSITION] == pos
assert m[Tag.ATK] == 1 and m[Tag.DAMAGE] == 0, "变形重置属性与伤害"
check_invariants(g)
return "变形保留 ENTITY_ID 与位置,重置属性"


def t_确定性回放():
def run(rng_source=None, seed=7):
g = Game(seed=seed) if rng_source is None else Game(seed=0)
if rng_source is not None:
g.rng = ReplayRNG(rng_source)
deck = ["WISP", "FIREBALL", "RIVER_CROC", "ARCANE_INT",
"BLIZZARD", "SPIDER_TANK"] * 3
g.setup(list(deck), list(deck))
for _ in range(6):
g.draw(0, 1); g.draw(1, 1)
return g

g1 = run()
log = list(g1.rng.log)
hand1 = [c[Tag.CARD_ID] for c in g1.zones.get(0, Zone.HAND)]
g2 = run(rng_source=log)
hand2 = [c[Tag.CARD_ID] for c in g2.zones.get(0, Zone.HAND)]
assert hand1 == hand2, (hand1, hand2)
return f"回放一致:{len(log)} 次 RNG 调用完全复现"


def t_随机对局不变式():
"""跑一批随机对局,全程检查不变式。"""
ALL = [k for k, v in CARD_DB.items()
if v.card_type in (CardType.MINION, CardType.SPELL)
and k not in ("ENCH_GENERIC",)]
games, turns = 0, 0
for seed in range(40):
rnd = random.Random(seed)
g = Game(seed=seed, policy=lambda p, o, k: o[0])
deck = [rnd.choice(ALL) for _ in range(30)]
try:
g.setup(list(deck), [rnd.choice(ALL) for _ in range(30)])
g.current = 0
g.begin_turn()
for _ in range(200):
pid = g.current
acted = False
p = g.player(pid)
for card in list(g.zones.get(pid, Zone.HAND)):
if g.compute_cost(card) > p[Tag.MANA_AVAILABLE]: continue
if card[Tag.LOCKED_UNTIL_TURN] > g.turn: continue
tg = None
if card.defn.targeting is not None:
opts = g.legal_targets(card)
if not opts: continue
tg = rnd.choice(opts)
g.play_card(card, target=tg)
check_invariants(g)
acted = True
break
if not acted:
attackers = [m for m in g.zones.get(pid, Zone.PLAY)
if g.can_attack(m)]
if attackers:
a = rnd.choice(attackers)
tg = g.legal_attack_targets(a)
if tg:
g.attack(a, rnd.choice(tg))
check_invariants(g)
acted = True
if not acted:
if g.turn > 40: break
g.end_turn()
check_invariants(g)
turns += 1
except GameOver:
pass
games += 1
return f"{games} 局随机对局 / {turns} 个回合,全部不变式通过"


TESTS = [
t_两个食尸鬼同时死亡, t_光环沉默致死, t_圣盾挡剧毒, t_亡语召唤位置,
t_触发顺序按上场时间, t_复生在亡语之后, t_巨型召唤部件,
t_战场已满召唤失败, t_突袭不能打脸, t_嘲讽限制目标, t_法术伤害加成,
t_狂乱一次性, t_超杀, t_法术迸发, t_任务完成, t_奥秘镜像实体,
t_过载, t_可交易, t_准备, t_发现, t_疲劳, t_变形保留位置,
t_确定性回放, t_随机对局不变式,
]


def main():
print("=" * 74)
print("stoneheart —— 迷你炉石引擎自检")
print("=" * 74)
passed = failed = 0
for t in TESTS:
name = t.__name__[2:]
try:
msg = t()
print(f" \033[32mPASS\033[0m {name:<24} {msg}")
passed += 1
except Exception as ex:
print(f" \033[31mFAIL\033[0m {name:<24} {type(ex).__name__}: {ex}")
import traceback; traceback.print_exc()
failed += 1
print("-" * 74)
print(f" {passed} passed, {failed} failed")
print("=" * 74)
return failed


if __name__ == "__main__":
raise SystemExit(main())