docs(lessons): Stress it sections for Modules 0 and 1; Blizzard tag names behind the SabberStone mismatch

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 09:16:47 +01:00
1 parent 638549161f
commit f26f461903
2 files changed
+87

No files matched your search

+27
View File
@@ -222,6 +222,8 @@ It works. But it is the same shape as your fix: a special case inside the code t
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.
Neither name is a typo for something that doesn't exist. HearthSim's [python-hearthstone](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py), a mirror of the game's own tag numbers, lists both in `GameTag`: `EXTRA_MINION_DEATHRATTLES_BASE = 371` and `EXTRA_DEATHRATTLES_BASE = 882`. A simulator that borrows the game's vocabulary inherits two near-identical names, and a special case on each side picked a different one. The same file lists `EXTRA_DEATHRATTLES_ADDITIONAL = 1131`. Keep that third name in mind; Module 4 comes back to it.
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
@@ -261,7 +263,32 @@ Module 0 is not started this way; `start-module` refuses `00`, because this modu
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.
## Stress it
From Module 1 on, every lesson ends with a **Stress it** section: a few questions that push the design past what the module's tests check. They are optional. Predict first, then open the discussion. Module 0's are on paper.
1. The naive engine has four cards. Suppose a set has 40 cards, and 5 of them are "this mechanic behaves differently while I'm in play" cards like Baron. If each of those 5 is a branch in the code that runs the mechanic it changes, how many branches is that? Now suppose two of them change the *same* mechanic and disagree (one says "twice", one says "never"). Where does the code that settles the disagreement go?
<details><summary>Discussion</summary>
Five branches is the cheap part. The expensive part is the pairs: any two modifiers of the same mechanic need a decision about how they combine, and in the naive shape that decision is one more `if` inside the function that already holds both branches. With *k* modifiers of one mechanic there are up to *k*(*k* − 1)/2 pairs. No one writes those branches down in advance. They get discovered as bugs, one interaction at a time.
The course's answer is to make each modifier a piece of data of a known *kind*, and to write the combining rule once per kind. Module 4 does it for "trigger N times" (two Barons), Module 7 for "this rule is negated", and Module 10 for the case where one of each meets the other. The count that matters stops being cards × cards and becomes kinds of modifier.
</details>
2. Your Module 0 fix probably asks "is a Baron on the board?" at the moment a Deathrattle runs. Reflect question 4 showed that this makes the answer depend on board position. List every *moment* at which an engine could ask that question during a Twisting Nether that kills Baron and Loot Hoarder together. How many are there?
<details><summary>Discussion</summary>
At least five: when the spell is cast; when each minion is marked for destruction; when the dead are removed from play; when the Hoarder's Deathrattle is queued; when it starts to resolve. A sixth is "whenever something asks", which is what the naive engine does, and it is not one moment but a different one in every code path.
The real game picks one of these on purpose and names it (Module 4's Aura Update). The useful habit to take from this module is the question, not the answer: whenever one rule reads state that another rule changes, ask *when* it reads. Modules 3, 4, 9 and 10 are each built around one instance of that question.
</details>
## Further reading
- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): 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](https://hearthstone.wiki.gg/wiki/Baron_Rivendare): the card page, with notes on when the doubling applies.
- HearthSim, [`hearthstone/enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py) (python-hearthstone): the game's tag and enum numbers, collected by the community. Search it for `EXTRA_`. It is a list of names and numbers, with no description of what the game does with them; read it as vocabulary, not as rules.
+60
View File
@@ -256,8 +256,68 @@ If you hit either, fix your engine (a normal commit, outside the drop), then red
</details>
## Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
1. A minion on the board is returned to its owner's hand and played again. After that: is its id the same? Is its order of play the same? Add a `bounce(id)` to your harness (board to hand, harness only, no engine change) and write the test before you run it.
<details><summary>Discussion</summary>
In an engine shaped like the reference, the id is the same and the order of play is new and larger. The id says *which card*; nothing about the card changed. The order of play says *when it entered play*, and it entered play again. If `bounce` is one call to your zone move and `play` is another, the stamp happens for free, for the same reason the Expansion Drop cost nothing.
Whether a card that changes zones should keep its identity is a real design fork, and the two big games chose differently:
- Hearthstone's data keeps the zone as a tag *on* the entity. python-hearthstone's `GameTag` has `ZONE = 49` and `ZONE_POSITION = 263`, and its `Zone` enum lists `PLAY`, `DECK`, `HAND`, `GRAVEYARD` and more ([`enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). That is the shape you built: one entity record, with a zone field that changes. (The enum file is names and numbers only. That entities keep their id across zones is the course's reading of that shape, not a quoted rule.)
- Magic: The Gathering says the opposite in so many words, rule 400.7: "An object that moves from one zone to another becomes a new object with no memory of, or relation to, its previous existence." ([Comprehensive Rules](https://magic.wizards.com/en/rules)). Under that rule a bounced creature forgets its damage, its enchantments and every effect that was tracking it, by definition.
Neither is wrong. "Same id, fields reset on a zone change" and "new id on every zone change" can express the same games. What differs is which mistakes are easy: with one id, forgetting to reset something; with new ids, forgetting to carry something over (Argentum's "Rules object identity across zones", in Further reading, is about exactly that).
</details>
2. Your ids are never reused. Is that a memory leak? A long game creates thousands of tokens. When would you have to reuse an id, and what would then stop an old handle from silently pointing at a new minion?
<details><summary>Discussion</summary>
For a card game it is not a problem worth solving: a game creates a few hundred entities, and a record per entity is small. Never reusing ids buys the strongest guarantee a handle can have, that it means one thing forever.
Engines that create and destroy objects by the million can't afford that, and reuse array slots. The standard protection is a **generation counter**: the handle is (slot index, generation), the slot stores its current generation, and a lookup with a stale generation fails instead of returning the slot's new occupant. Andre Weissflog's [*Handles are the better pointers*](https://floooh.github.io/2018/06/17/handles-vs-pointers.html) describes the scheme (his 2018 update adds "a per-slot generation counter" to prevent "handle collisions"), and Catherine West's RustConf 2018 keynote calls it by its usual name: "Generational indexes are awesome." ([*Using Rust for Game Development*](https://kyren.github.io/2018/09/14/rustconf-talk.html)). Her talk is also the best account of why Rust pushes you to ids in the first place.
Notice what both schemes share with yours: a stale handle must be *detectably* stale. An index into the board fails that test, which is Predict question 2, and it will come back in Module 3.
</details>
3. Write down three facts that are true of your world after *any* sequence of `summon`, `addToHand`, `addToDeck`, `play` and `bounce` calls, whatever the order. Then write one test that makes 200 random calls and checks all three after each call.
<details><summary>Discussion</summary>
Three that hold in the reference:
- Every id is in exactly one zone list, and that list belongs to the zone the entity record names.
- No two minions on the board (either board) have the same order of play.
- An entity has an order of play if and only if it has been on the board at some time.
These are **invariants**: facts no operation may break. The four Module 1 tests each check one path through the engine; an invariant test checks the same few facts on paths nobody thought to write. It needs no library, only a random number generator you seed yourself, so a failure can be replayed:
```text
seed = 12345
next(): seed = (seed * 1103515245 + 12345) mod 2^31; return seed
repeat 200 times:
pick an operation with next(); if it needs an id, pick one that is legal for it
do it
assert every invariant
on failure: print the seed and the operations so far
```
One thing a tempting fourth invariant gets wrong: "the board list is sorted by order of play" is false. It holds while every minion arrives at the right end, and stops holding the first time one arrives anywhere else (Module 3's Deathrattle summons). Position and order of play are different facts, which is the point of this module. [Module 11](../11-break-your-engine/lesson.md) takes this style of testing further.
</details>
## Further reading
- Celia Wagar, [*Card Game Design as Systems Architecture*](https://critpoints.net/2023/05/26/card-game-design-as-systems-architecture/) (CritPoints, 2023). A designer's essay on why card games behave like long-lived software: "Card games are surprisingly a lot like enterprise web applications." Read it for the framing of the whole course; it has no engine code.
- Argentum, [`docs/architecture-principles.md` §2.2, Entity-Component-System](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md#22-entity-component-system-ecs). Read the principle and the subsection "Rules object identity across zones". Note that its id allocator "is independent of timestamps used to order continuous effects": the same split between identity and ordering you built here.
- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): read the definition of order of play and Rule 2 on Queues. Module 2 builds the Queue.
- Andre Weissflog, [*Handles are the better pointers*](https://floooh.github.io/2018/06/17/handles-vs-pointers.html) (2018). The case for handles from the systems side: "move all memory management into centralized systems" and "only return an 'index-handle' to the outside world". Read the update at the end on generation counters.
- Catherine West, [*Using Rust for Game Development*](https://kyren.github.io/2018/09/14/rustconf-talk.html) (RustConf 2018 closing keynote, written up). A long walk from an object-oriented game design to ids and generational indexes, driven by the borrow checker. Worth reading on the Rust track before Module 2.