炉石传说引擎深度解剖
关键词编年史、核心算法与数据驱动卡牌系统
一份面向工程师的、可落地的卡牌游戏引擎设计笔记
版本覆盖: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.unity3d与CardDefs.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 约束:这不是一个可以随便重构的系统
炉石有几个特殊约束,让它的架构选择与一般游戏不同:
- 服务器权威、客户端零逻辑。所有规则计算在服务器完成,客户端只播放一串”发生了什么”的指令流(社区称为
PowerHistory)。这意味着引擎必须能把状态变化序列化成一个线性的、可重放的事件流。 - 狂野模式永不轮换。2014 年的卡必须和 2026 年的卡在同一局里正确交互。这排除了”新版本重写旧卡”的选项。
- 移动端。引擎要在十年前的手机上跑动画,状态同步的带宽和客户端算力都很紧张。
- 热更新卡牌。平衡性调整(改费用、改身材、改文本)必须能通过数据下发完成,不能每次都发客户端版本。
约束 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
一个随从的攻击力从哪来?至少三个地方:
- 基础值:卡牌定义里写的(
EX1_001基础 3/2) - 附魔(Enchantment):持久的、附着在实体上的修饰(《王者祝福》+4/+4,永久生效直到沉默或死亡)
- 光环(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 改变) | 附魔全部丢失,亡语不触发 |
| 死亡后被《复活术》复活 | 否,新实体 | 原实体永久留在墓地 |
| 从战场返回手牌 | 是,但清除附魔(回到基础状态) | 这是《撒满谎言的书》能”洗白”的原因 |
| 被《心灵控制》 | 是,改 CONTROLLER | OWNER 不变,所以”返回拥有者手牌”会送回去 |
| 被复制(《克隆》) | 否,新实体 | 复制的是当前状态快照还是基础卡?取决于卡(这是历史 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 战场已满:一个精心设计的失败路径
“战场满了会怎样”是一道经典面试题,因为它有五种不同答案:
- 主动出牌:出不了,UI 直接禁用。
- 战吼召唤(如《野猪骑士》):召唤失败,什么都不发生,不触发亡语,不进墓地,实体直接消失。
- 亡语召唤(如《蜘蛛坦克》):同上,但注意是在死亡结算阶段判定的,此时可能已经空出位置了。
- 《军情七处》类偷取对方随从:如果我方满了,随从留在原地。
- 《镜像实体》类奥秘:如果满了,奥秘照样消耗(这是被投诉过很多次的裁定)。
关键:“召唤失败”和”死亡”是两件完全不同的事。前者根本没进入 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},但订阅DEATH且source_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。这是错的,因为:
- 源离场和新随从上场的顺序会导致漏加/漏减
- 光环有条件(《雷诺》类”如果你的牌库没有重复卡”),条件变化时要重算
- 光环可以嵌套(A 的光环让 B 的光环生效)
- 沉默、变形、心灵控制都会改变光环的适用范围
真实引擎的做法是脏标记 + 全量重算:
# 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 血随从,其中一个是《不稳定的食尸鬼》。
如果立即死亡,第一个随从死亡时就会触发亡语,亡语的伤害会打到”还没受到暴风雪伤害”的随从上。结果完全错误。
正确做法是两阶段:
- 伤害阶段:只扣血,只标记
TO_BE_DESTROYED,不触发任何死亡逻辑 - 死亡结算阶段(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。 你的随从有《剧毒》。
如果不快照,会发生:
- 你的随从对它造成 3 伤 → 它变成 3/3
- 它对你造成伤害时读
ATK→ 还是 3(没变)
看起来没问题。但换个场景:
你的 4/4 攻击对方的《不稳定的食尸鬼》,你场上还有一个 1/1。 食尸鬼是 1/1。
- 你的 4/4 打死食尸鬼
- 食尸鬼对你造成 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
问题不只是长。真正的问题是:
- 新卡必须改引擎代码 → 必须发客户端版本 → 无法热更新
- 无法做平衡性调整(改数值要改代码)
- AI 无法理解卡牌(只能靠模拟,不能做启发式估值)
- 无法自动生成卡牌文本(本地化噩梦)
- 无法静态分析(”哪些卡会造成伤害?”这个查询要靠 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 排序与随机的确定性问题
RandomPick 和 TopN 有一个隐蔽的陷阱:平局怎么办?
“攻击力最高的敌方随从”——如果有两个 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?
因为附魔需要:
- 携带触发器:《不稳定的传送门》的折扣、《暗影狂乱》的”回合结束时归还”、《回响》的”回合结束时消失”
- 被独立引用:《恩佐斯,深海领主》要复制”随从死亡时的附魔状态”
- 被网络同步:客户端要显示附魔图标和 tooltip
- 有来源:《沉默》要区分”这个 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 章已经给过,重点是三条:
- 圣盾在
PRE_DAMAGE之后消耗,所以”如果这次伤害是 0,圣盾不破” - 圣盾破了之后
damage()返回 0,所以剧毒/吸血/超杀都不生效 - 圣盾只挡伤害,不挡消灭(《暗影词术:死亡》照样杀)
# 反例:错误实现
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? 因为:
- 客户端要显示两张独立的卡面(有各自的插图和文本)
- 《弗洛普的槌子》类”两种效果都触发”需要独立执行两张子卡
- 目标筛选可能不同(一个要目标,一个不要)
- 费用可能不同(历史上《月火术》类)
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
为什么发现如此成功?三个工程/设计层面的理由:
- 它把随机性变成了决策。RNG 生成三个选项,但玩家做选择。这消除了”被随机数杀死”的挫败感,同时保留了多样性。
- 它是版本无关的。卡池是动态查询的,新资料片自动加入,老卡自动轮换出去。一张 2015 年的发现卡在 2026 年依然工作,且效果完全不同。这是数据驱动的最高价值体现。
- 它的实现是可复用的。后来的《适应》《打捞》《挖掘》《锻造》《黑暗恩赐》全都是 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) # 手牌里变形!
工程意义:这是第一次在手牌里做变形。它要求:
- 手牌实体能注册触发器(
zones={HAND}) - 变形能在非战场区域工作
- 客户端能显示”手牌里的卡变了”的动画
后来的《任务链》《锻造》《碎裂》全都建立在这套基础设施上。
第 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)
磁力的四个陷阱:
- 沉默融合体 → 所有磁力部件的效果一起消失,随从回到基础形态。这是因为所有磁力效果都挂在附魔上。
- 融合体死亡 → 所有磁力部件的亡语都触发(按融合顺序)。
- 磁力随从可以正常打出(不融合),此时它就是个普通随从。
- 不能融合到非机械上,也不能融合到”已经满了”的随从上(无此限制,可以无限叠)。
工程评价:磁力是”关键词复杂度”的一个上限案例。它需要 Action 能修改另一个实体的触发器注册表,这打破了”实体的行为由它的 CardDef 决定”这条不变式。真实炉石的实现(推断)确实是通过附魔携带触发器来做的,这也是为什么附魔必须是实体(13.1)。
其他
- Omega:
If(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)
陷阱清单:
- 复生体是新实体(新 ENTITY_ID),所以”这个随从”类的引用会断
- 复生体不继承附魔(回到基础卡 + 1 血)
- 复生在亡语之后(第 7 章讲过)
- 复生体的
REBORN被清除,不能二次复生 - 沉默会移除复生(
REBORN在SILENCEABLE_TAGS里) - 场满时复生失败(和普通召唤一样)
- 复生体会触发”随从召唤时”效果
为什么复生的实现要放在死亡结算的第 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 在手牌里必须严格维护。之前手牌的位置只是显示顺序,现在它是规则的一部分。这意味着:
- 抽牌插入的位置必须确定(总是最右)
- 生成的牌插入位置必须确定(总是最右)
- 打出一张牌后,右侧所有牌左移
- 客户端拖动排序不能影响服务器状态(炉石不允许玩家排手牌,正是因为这个)
第 4 点很关键。如果玩家能自由排手牌,流放就没有意义了。所以炉石故意不提供手牌排序功能——这是一个由机制倒逼的 UX 决策。
(2026 年的《碎裂》把这个约束用到了极致:碎片被强制放在手牌两端。)
至尊(Prime)
文本:亡语:将一张”至尊版”洗入你的牌库。
纯组合:deathrattle = ShuffleIntoDeck("BT_XXXt")。零新基础设施。
恶魔猎手职业
工程意义:第十个职业。它验证了”职业是数据”这个设计——新增一个职业只需要:
- 一个
CLASS枚举值 - 一个英雄实体定义
- 一个英雄技能定义
- 卡牌的
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)
# ★ 交易不算"打出一张牌",不触发连击、不触发"使用一张牌后"
工程意义:可交易是第一个”非出牌”的手牌操作。之前手牌里的卡只有两个出路:打出、弃掉。可交易加了第三个,这要求:
- UI 增加一个手势(拖到牌库)
- 服务器增加一个 action 类型
- 网络协议增加一个消息
- 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)
巨型的陷阱:
- 场地不够时,主体优先,部件能放几个放几个
- 部件是独立随从,可以被单独消灭
- 部件死亡不影响主体
- 主体死亡不影响部件
- 部件通常有
UNTOUCHABLE或特殊限制(如”不能攻击”) - 复制主体不会复制部件(
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)
新增资源类型的工程代价:
- 一个玩家级 tag(便宜)
- UI 显示(中等)
- 卡牌的”可用性”判定要考虑尸块(
can_play增加一个条件) - 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.choose 从 int 变成 list[int],resolve_choose_one 循环执行。约 15 行改动。
25.2 失落的安戈洛之城(2025.07)——同源、任务回归
同源(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)——倒带、传奇
倒带(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 个调用点手动维护不变式,不如在状态变化的唯一入口做统一检查。前者随着卡池增长会漏掉某个路径,后者永远正确。
碎裂的策略深度:因为手牌是从右侧添加的,你打出中间的牌会让两半靠近。这创造了一个”手牌管理”的新维度——玩家要主动规划出牌顺序来合并碎片。同时,它也惩罚了满手牌(碎片被隔得很远)。
碎裂的实现陷阱:
- 两半在手牌上限计算里算两张牌
- 单独打出一半是合法的(较弱版本),另一半会永久留下
- 复制一半 → 复制品的
shatter_pair_id指向已经配对的那个,会导致错误合并(需要在复制时清除配对关系) - 被对手偷走一半(《心灵尖啸》类)→ 配对断裂
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 点法力值
- 消耗量不超过把费用减到 0 所需的量
- 每张牌只能准备一次
- 折扣在被复制或被偷取时保留
- 折扣在被洗入牌库或打出后返回手牌时重置
- 准备后本回合锁定在手中
工程分析:准备是第四个非出牌手牌动作(前三个:打出、弃掉、交易),也是第二个消耗法力但不打出牌的动作。
它的第 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_card的controller参数能指向对手 - 贿赂卡:给自己强力效果,同时给对手一点补偿
“在对手场上打出随从” 是一个真正的新能力。它要求:
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 # ★ 但拥有者还是我
CONTROLLER 与 OWNER 分离的设计(第 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_CARD | source_only | — |
| 亡语 | DEATH | source_only | — |
| 励志 | HERO_POWER_USED | 己方 | ✗ |
| 超杀 | DAMAGE | 我造成 & 溢出 & 我的回合 | ✗ |
| 荣誉击杀 | DAMAGE | 我造成 & 恰好致死 | ✗ |
| 狂乱 | DAMAGE | 打到我 & 我活着 | ✓ |
| 法术迸发 | AFTER_SPELL | 己方 | ✓ |
| 过量治疗 | OVERHEAL | 打到我 | ✗ |
| 开局时 | GAME_START | 在牌库 | ✓ |
| 抽到时施放 | DRAW | source_only | ✓ |
| 快速射击 | PLAY_CARD | 本回合抽到 | — |
实现成本:低(一个 TriggerDef) 组合性:好,但有时序问题 占比:约 35%
设计经验:事件上下文要携带尽可能多的信息(amount、target_health_before、fatal、paid_cost),这样新关键词只需要加一个 Condition。
范式三:出牌流程钩子(Play Hook)
定义:在 play_card 的某个阶段插入逻辑,或者增加一个全新的手牌动作。
| 关键词 | 钩子位置 |
|---|---|
| 过载 | 打出后(资源) |
| 连击 | 打出前(条件分支) |
| 抉择 | 打出前(选项) |
| 回响 | 打出后(生成副本) |
| 双生法术 | 打出后(生成副本) |
| 流放 | 打出前(位置条件) |
| 终曲 | 扣费后(法力条件) |
| 法力渴求 | 打出前(历史条件) |
| 可交易 | 新动作 |
| 锻造 | 新动作 |
| 准备 | 新动作 |
| 使用地标 | 新动作 |
| 泰坦技能 | 新动作 |
实现成本:中(要改主流程) 组合性:差(钩子之间可能冲突) 占比:约 20%
设计经验:“新动作类型”是最贵的关键词。它影响 UI、协议、AI 搜索空间。炉石十二年只加了五个,非常克制。
范式四:选择型(Choice)
定义:需要中断结算,等待玩家输入。
| 关键词 | 池子 | 数量 |
|---|---|---|
| 发现 | 动态查询 | 3 |
| 适应 | 固定 10 | 3 |
| 打捞 | 牌库底 3 | 1(全展示) |
| 挖掘 | 分层池 | 1(随机,不选) |
| 黑暗恩赐 | 固定池 | 3 |
| 抉择 | 固定 2 | 1 |
实现成本:高(需要挂起/恢复机制) 组合性:差(不能嵌套) 占比:约 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 ░░░░░░████░░████████ 多实体(碎裂、先驱)
图例:静态标签 / 触发器 / 出牌钩子 / 选择型 / 状态机 / 多实体
趋势解读:
- 静态标签在 2016 年后基本饱和。所有”简单的持续属性”都被用光了。
- 触发器保持稳定,因为事件空间在扩大(新增事件 = 新的触发机会)。
- 状态机在 2017 年后成为主力。它提供”跨回合的目标感”,是留存率的好朋友。
- 多实体在 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/1
- 变形保留
EXHAUSTED吗?保留——变形不能解除召唤失调 - 变形保留
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]
关键点:
- 记录调用日志,不只是 seed。因为如果代码逻辑变了(比如某个卡的实现改了消费顺序),单靠 seed 无法复现旧对局。
- 传入的序列长度也要记录,这样能在回放时校验状态一致性。
- 绝对不能有第二个随机源。任何
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 张,牌库里还有牌。你抽了一张。
问题:会发生什么?
答案:牌被”烧掉”——从牌库移除,直接进墓地,玩家看到一个燃烧动画。
微妙之处:
- 烧掉的牌算作”抽到了”吗?——算。所以《过度抽牌》类的”抽 N 张”会真的抽 N 张,只是烧掉。
- 烧掉的牌会触发”抽到时”效果吗?——不会。因为它没进手牌。
- 但《抽到时施放》的法术呢?——会施放(实测行为)。这是个特例。
- 烧牌触发”你弃掉一张牌”吗?——不会。烧牌和弃牌是不同事件。
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 三个必须满足的性质
一个卡牌游戏的随机系统必须同时满足:
- 不可预测:玩家(包括作弊客户端)不能提前知道结果
- 可复现:服务器能从日志完整重放一局,用于 bug 排查和回放功能
- 不可污染: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)。
这个模型的收益:
- 零作弊面。客户端改内存最多能看到它本来就知道的东西
- 零回滚代码
- 回放免费(PowerHistory 就是回放数据)
- 观战免费(同一份 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。在移动网络上这是可接受的,但可以优化:
- 变长整数编码(protobuf varint):tag 值大多是小整数
- 实体 ID 相对编码:连续的 change 常常针对同一实体
- 批量位置更新:
ZONE_POSITION的重排可以压成一条消息 - 省略可推导的变化:客户端能自己算出来的不发(但这会引入”客户端逻辑”,慎用)
炉石实际用的是 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 更实用,因为:
- 炉石的回合内决策没有随机性影响顺序(大部分情况)
- 评估函数已经足够好
- 可预测的时间开销(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. 事件上下文宁可多带信息
amount、target_health_before、fatal、paid_cost、snapshot……
一次多存一个字段,省了后面三个关键词的改动(超杀/荣誉击杀/狂乱共用 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_DEPTH、MAX_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)- 单元测试:区域移动的不变式
关键决策点:把 CONTROLLER 和 OWNER 分开(哪怕你现在用不到),把 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 年的引擎里就有:
CONTROLLER与OWNER分离(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 Effect | DSL 无法表达时的硬编码入口 |
附录 C:参考与来源
事实性内容(版本时间线、关键词文本、规则裁定)来源:
- Hearthstone Wiki: Expansion
- Hearthstone Wiki: Keyword
- Hearthstone Wiki: Prepare
- Hearthstone Wiki: Escape from Violet Hold
- Hearthstone Wiki: CATACLYSM
- Blizzard: Step Into the Emerald Dream
- Blizzard: Across the Timeways
- Blizzard: Break the Rules in Escape from Violet Hold
- esports.gg: Rewind and Fabled keywords
- esports.gg: Imbue, Dark Gifts
- esports.gg: Lost City of Un’Goro, Kindred
- HearthstoneTopDecks: CATACLYSM Guide
关于实现细节的免责声明:本文中所有引擎实现(类名、数据结构、算法流程)都是基于公开可观察行为的重构与推断,不是暴雪的源代码。凡标注”推断”处尤其如此。真实的炉石客户端是 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())