verify-references ran a bare `npx vitest run` in the staged reference, which fetched an unrelated vitest that could not load the reference config. It now installs the reference's devDependencies first. sync-check also checks each module's expansion/tests against ### headings in expansion/README.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
16 KiB
Module 0: The Rewrite Trap
This module is an orientation. You get a small engine that is written the obvious way, you add one famous card to it, and you keep a record of what that cost you. Then you throw the engine away. Your real engine starts in Module 1.
Module 0 has no Spec, Build or Expansion Drop beats and no reference solution. Nothing here "fixes" the naive engine. The point is to feel the problem before the course names it.
You will need:
- the course repository, checked out;
- Rust (
cargo) or Node 24 with npm, whichever track you plan to take. You can try both.
Observe
Baron Rivendare is a 4-mana 1/7 Legendary minion from Curse of Naxxramas. His card text (Baron Rivendare, hearthstone.wiki.gg):
Your minions trigger their Deathrattles twice.
Loot Hoarder is a 2-mana 2/1 with this text (Loot Hoarder, hearthstone.wiki.gg):
Deathrattle: Draw a card.
With Baron on your board, a dying Loot Hoarder draws two cards. The wiki says the Deathrattle is queued once and that "the second activation of the Deathrattle is immediately after the first activation resolves" (Baron Rivendare).
Baron does not change Loot Hoarder, and Loot Hoarder knows nothing about Baron. One card changes how a whole mechanic behaves. Players think of this as a single sentence of card text. In code, that sentence has to land somewhere.
Predict
Write your answers down before you open the discussion. A file such as notes/00.md in your own workspace is fine.
- Before you read the engine: how many functions do you expect to change to add Baron Rivendare?
- You have Acolyte of Pain (played first) and Loot Hoarder on the board. You cast Whirlwind. Write the order of events you expect: each damage, each trigger, each draw, each death.
The three cards involved (Whirlwind, Acolyte of Pain, Loot Hoarder):
- Whirlwind, 1-mana spell: "Deal 1 damage to ALL minions."
- Acolyte of Pain, 3-mana 1/4: "Whenever this minion takes damage, draw a card."
- Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card."
Discussion: the Whirlwind order
The real game deals all of the damage first, then resolves the damage triggers, and only then processes deaths:
- Damage to Acolyte of Pain, then damage to Loot Hoarder. For area damage, "all of the Damage/Healing Events are created in play order" and then resolved in play order (Advanced rulebook). The rulebook's Frothing Berserker example puts it plainly: "all the damage occurs, then each 'On Damage' Event is resolved based on the order of play."
- Acolyte of Pain's trigger: draw a card.
- Loot Hoarder dies. A minion at 0 Health is only mortally wounded until the outermost Phase ends: "mortally wounded and pending destroy are ONLY converted into dead once the outermost Phase ends!" (Advanced rulebook).
- Loot Hoarder's Deathrattle: draw a card. Deathrattles resolve in a Death Phase after the Death Creation Step (Advanced rulebook).
The naive engine's test suite states exactly this order in test_00_real_rules_whirlwind_order:
game.summon("Acolyte of Pain"); // played first
game.summon("Loot Hoarder");
game.cast("Whirlwind");
assert_eq!(
game.log,
vec![
"damaged Acolyte of Pain 1",
"damaged Loot Hoarder 1",
"triggered Acolyte of Pain",
"drew a card",
"died Loot Hoarder",
"deathrattle Loot Hoarder",
"drew a card",
]
);
game.summon("Acolyte of Pain"); // played first
game.summon("Loot Hoarder");
game.cast("Whirlwind");
expect(game.log).toEqual([
"damaged Acolyte of Pain 1",
"damaged Loot Hoarder 1",
"triggered Acolyte of Pain",
"drew a card",
"died Loot Hoarder",
"deathrattle Loot Hoarder",
"drew a card",
]);
The naive engine cannot pass it, so the test is marked expected-to-fail (#[should_panic] in Rust, test.fails in TypeScript). Remove the marker and run the tests to see the order the engine really produces: Acolyte is damaged and draws before Loot Hoarder is even hit. If your prediction matched the engine rather than the game, that is the point of this module. The obvious code gives an order that looks reasonable and is wrong.
Run the naive engine
The naive engine lives in naive-engine/rust/ and naive-engine/ts/ in the course repository; each has a README. Each is one engine file of about 100 lines plus a test file. Read the engine file end to end before you change anything. It is short.
cd naive-engine/rust
cargo test
cd naive-engine/ts
npm install
npx vitest run
Both suites should report 2 passing tests: test_00_naive_runs passes normally, and test_00_real_rules_whirlwind_order passes because it fails as expected.
Note what the engine does:
- Minions are shared references (
Rc<RefCell<Minion>>in Rust, plain objects in TypeScript). deal_damage/dealDamagelowers Health, runs the minion's damage trigger at once, and if Health is 0 or less, removes the minion and runs its Deathrattle at once.- Every card is defined inside the engine, in
make_minion/makeMinionandcast.
Add Baron Rivendare
In the track of your choice, add Baron Rivendare to the naive engine:
summon("Baron Rivendare")puts a 1/7 Baron on the board.- While Baron is on the board, Loot Hoarder's Deathrattle happens twice when it dies, so it draws two cards.
- Write a test for it next to the existing ones (any name you like) and make it pass.
Do the simplest thing that works. Don't redesign the engine; the redesign is the rest of the course.
As you work, keep a log in your notes. Write down every file and every function you touch, and why:
| File | Function | What you changed and why |
|---|---|---|
How the real game differs The naive engine has one player, no hand and no deck: "draw a card" only counts draws. It resolves damage, triggers and deaths in the wrong order on purpose (see Predict). Baron's "Your minions" has no meaning yet because nothing records who owns a minion. Your real engine fixes all of this from Module 1 on.
Reflect
Look at your log, then answer these before opening each discussion.
- Which functions did you change? Is one of them the function that runs Deathrattles? Does the engine now contain the words "Baron Rivendare" outside the card definitions?
Discussion
Most learners end up with a check inside the death code: "if a Baron is on the board, run the Deathrattle again". It works, and the test passes. But the engine now knows about one specific card, and the function that runs Deathrattles is the place every future Deathrattle rule has to be squeezed into.
From Module 1 on, the course enforces the Zero-Coupling Law: engine code never names a specific card. A coupling audit checks it. The fix you just wrote would fail that audit.
- Now picture the next cards. Brann Bronzebeard: "Your Battlecries trigger twice." (Brann Bronzebeard). A card that doubles only the Deathrattles of one minion type. A card that makes Deathrattles trigger three times. How many of these would need another edit to the same function?
Discussion
Each of them is another branch in the code that runs triggers. They all share a shape ("when a trigger of kind X fires, fire it N times") but the naive engine has nowhere to put a shape. It only has places to put special cases. Module 4 turns "trigger twice" into data that one generic piece of the engine consults.
- Put a second Baron Rivendare on the board. What does your engine do when Loot Hoarder dies? What does the real game do?
Discussion
In the real game, "Rivendare's ability is not duplicated or multiplied by having more than one Baron Rivendare in play." (Baron Rivendare). If your fix loops over the board and fires the Deathrattle once per Baron, you draw three cards instead of two. If you check "is any Baron on the board", you got this one right, perhaps by luck.
- Twisting Nether: "Destroy all minions and locations." (Twisting Nether). Suppose you added it to the naive engine the obvious way, killing each minion in board order. Baron and Loot Hoarder are on the board. How many cards do you draw? Does the answer depend on which of them is further left?
Discussion
In the real game you draw one card: "if he dies at the same time as a minion with a Deathrattle, the Deathrattle is not doubled." (Baron Rivendare). The rules behind this: deaths are processed together when the outermost Phase ends, and Baron's doubling is an aura that is updated after the Death Creation Step, so it is already gone when the Deathrattle resolves (Advanced rulebook).
In the naive engine there is no "at the same time". Each minion dies alone, the instant its Health hits 0. If Baron dies first you draw one card; if Loot Hoarder dies first you draw two. The result depends on board position, which the real game never consults here.
- Why can't you make
test_00_real_rules_whirlwind_orderpass with a small edit?
Discussion
Because one function call does three jobs. In the naive engine, dealing damage is running the trigger is killing the minion:
pub fn deal_damage(&mut self, target: &MinionRef, amount: i32) {
// ...lower health, log "damaged"...
if let Some(effect) = on_damaged {
effect(self, target); // trigger runs now
}
if target.borrow().health <= 0 {
self.kill(target); // death and Deathrattle run now
}
}
dealDamage(target: Minion, amount: number): void {
// ...lower health, log "damaged"...
if (target.onDamaged) {
target.onDamaged(this, target); // trigger runs now
}
if (target.health <= 0) {
this.kill(target); // death and Deathrattle run now
}
}
To get the real order you have to separate asking for something from doing it (Module 2) and decide death at defined checkpoints instead of the moment Health reaches 0 (Module 3). Neither fits inside the existing function. Both need a different engine shape.
One more thing to notice: Whirlwind loops over a copy of the board holding direct references. A minion that has already died is still in that copy. If a later effect in the loop hit it again, the engine would happily kill it a second time. Module 1 replaces references with IDs for this reason.
The trap in the wild
The naive engine is not a strawman. Two open-source Hearthstone simulators handle Baron Rivendare the same way. Read the linked code; it is not reproduced here.
Fireplace (Python, jleclanche/fireplace, read at commit 47a2572):
- Baron is an aura that sets a player-level flag:
class FP1_031infireplace/cards/naxxramas/collectible.pyrefreshesGameTag.EXTRA_DEATHRATTLESon its controller. - The doubling happens inside the Deathrattle action itself:
class Deathrattleinfireplace/actions.pyhas anif target.controller.extra_deathrattles:branch that queues the Deathrattle's actions a second time.
It works. But it is the same shape as your fix: a special case inside the code that runs Deathrattles. "Trigger three times" or "only for one minion type" means editing that branch again.
SabberStone (C#, HearthSim/SabberStone, master, last commit January 2021):
- Baron's aura sets
GameTag.EXTRA_MINION_DEATHRATTLES_BASE:SabberStoneCore/src/CardSets/NaxxCardsGen.cs. - The Deathrattle task checks a different tag,
GameTag.EXTRA_DEATHRATTLES_BASE, before running the Deathrattle again:SabberStoneCore/src/Tasks/SimpleTasks/ActivateDeathrattleTask.cs. - The controller aura lookup in
SabberStoneCore/src/Enchants/AuraEffects.cshas no case for either tag. - The Rivendare test in
SabberStoneCoreTest/src/CardSets/NaxxCardsGenTest.csis marked[Fact(Skip = "ignore")].
From reading the code, the doubling appears broken; its Rivendare test is skipped. (We have not built or run SabberStone to confirm.) The producer of the rule and its consumer are two hand-written special cases in different files, and nothing connects them except a tag name that has to match by hand.
Both projects are written by capable people. The trap is not carelessness. It is what you get when "trigger twice" has nowhere to live except inside the code that runs triggers.
Before Module 1: choose your track
Your real engine is a new project, built from a template. The naive engine stays behind.
Pick a track. Rust or TypeScript. The tests, test names and lessons are the same in both. Choose the language you would rather think in. Rust's borrow checker pushes you towards IDs and explicit ownership early, which suits this course. TypeScript lets you move faster and makes it easier to fall into the habits this course warns about, which is also instructive.
Copy the template to a directory outside the course repository, and put it under git. Expansion Drops are checked with git diff, so your workspace needs its own history.
cp -r workspace-template/rust ~/my-engine
cd ~/my-engine
git init && git add -A && git commit -m "start from template"
cargo test
cp -r workspace-template/ts ~/my-engine
cd ~/my-engine
git init && git add -A && git commit -m "start from template"
npm install
npm test
Copy the whole directory with cp -r so the hidden .course.json file (which records your track) comes along. With no tests yet, both cargo test and npm test report 0 tests and exit cleanly.
Read the template's README. Your engine goes in src/engine/, your cards in src/content/, and src/harness is the thin adapter the course's tests call.
When you start Module 1, run this from the course repository to copy its tests into your workspace:
node tools/start-module.mjs 01 ~/my-engine
Module 0 is not started this way; start-module refuses 00, because this module uses the naive engine instead of your workspace.
Keep your Module 0 log. You will want it when you reach Module 4 and add Baron Rivendare again, this time without touching the engine.
Further reading
- Advanced rulebook, hearthstone.wiki.gg: the community's specification of Sequences, Phases, Queues and death processing. Skim the sections on Phases and the Death Creation Step now; Modules 2 to 4 go through them in detail.
- Baron Rivendare, hearthstone.wiki.gg: the card page, with notes on when the doubling applies.