docs(lessons): Stress it sections for Modules 2-5; handle-based summon position in Module 3; Brann stacking and tag pair in Module 4

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:19:32 +01:00
1 parent 493a2af854
commit f0aec2f620
4 files changed
+238 -13

No files matched your search

+66
View File
@@ -329,9 +329,75 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
</details>
## Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
1. Add a fictional card to your content: Test Bomb, 1/50, "Whenever a minion takes damage, deal 1 damage to all minions." Summon one and deal it 1 damage. Before you run it: what does your engine do, and what is in the log when it stops?
<details><summary>Discussion</summary>
It never stops on its own. The Bomb's trigger damages the Bomb, which queues the Bomb's trigger, inside itself. The reference engine at this module recurses until the language runtime gives up: in the TypeScript track that is a `RangeError` after about two thousand log events, with the Bomb at −936 Health. A stack overflow in the middle of a Sequence also leaves the world half-changed, which is worse than the crash.
Depth-first resolution by plain recursion is the right model and it has this cost: nothing bounds the depth. Two real games answer in two different ways.
- **Forbid it by rule.** The Advanced rulebook: "A trigger cannot be nested inside itself" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). In engine terms: keep the set of (source, trigger) pairs that are resolving right now, and don't queue an entry that is already in it. Try it. With the guard, the Bomb damages everything once and the Sequence ends: its trigger is on the stack when its own damage lands, so it isn't queued again.
- **Allow it and name the outcome.** Magic: The Gathering has no such guard. Rule 104.4b: "If a game ... somehow enters a 'loop' of mandatory actions, repeating a sequence of events with no way to stop, the game is a draw." ([Comprehensive Rules](https://magic.wizards.com/en/rules)). An engine for that game has to *detect* the loop, which is harder than preventing it.
Either way the decision belongs in the engine's one resolution loop, and it is a rule of the game, not an implementation detail: two Bombs under the guard still hit each other once each, and a player can see that.
The guard is not part of the course's tests, and the reference doesn't have it; [Module 3](../03-death-step/lesson.md) says why it can wait. If you add it, add a test with it.
</details>
2. Make your engine breadth-first on purpose. Keep one list of pending triggers for the whole Sequence; when a trigger's action causes new triggers, append them to the end. Which of the module's four tests fails, and with what order? What did you gain?
<details><summary>Discussion</summary>
Only `test_02_depth_first_nesting`: the triggers come out Echo, Armorsmith, Reactor, and `ArmorGained` precedes Reactor's `StatsChanged`. The other three pass, which is worth noticing. Area damage first, mortally wounded minions that still react, and fatigue are all independent of the depth-first rule. One test in the module pins it.
What you gained is that the breadth-first engine can't overflow the stack: question 1's Bomb fills a list instead (forever, but politely). That is why queues are the pattern people reach for first, and why Nystrom's advice in Name it is to avoid raising events from handlers. If you want depth-first order *and* no recursion, keep an explicit stack and push a trigger's consequences on top of it, to be resolved before what was already waiting. That is Slay the Spire's "add to top" from Further reading, and it is the same order your recursion produces.
</details>
3. Your log is a flat list. The real game's is not. HearthSim's parser for Hearthstone's `Power.log` describes it: "The `BLOCK_START` and `BLOCK_END` packets are serialized into a Block packet, which is nestable." ([python-hslog README](https://github.com/HearthSim/python-hslog)). The block types include `PLAY`, `POWER`, `TRIGGER` and `DEATHS` (`BlockType` in [python-hearthstone's `enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). What would your engine have to record so that the Echo / Armorsmith / Reactor log could be printed as an indented tree? Add it, and print the tree.
<details><summary>Discussion</summary>
One number: the nesting depth at which each event was recorded (or, equivalently, a "block opens" and a "block closes" marker around each trigger's resolution). Your `react` already knows it; it is the depth of the recursion.
```text
Damaged(Armorsmith, 1)
Triggered(Echo)
Damaged(enemy hero, 1)
Triggered(Reactor)
StatsChanged(Reactor, 2, 5)
Triggered(Armorsmith)
ArmorGained(0, 1)
```
Printed this way the depth-first rule is visible at a glance: Reactor's line is *inside* Echo's block, and Armorsmith's block starts only after Echo's has closed. A breadth-first engine can't print this tree at all, because its events have no single parent block that is still open. The nested blocks in the game's own log are consistent with the rulebook's "similar to a depth-first search"; that is the course's reading of the log format, not a statement from Blizzard.
Keep the tree printer. It is the most useful debugging tool you will have in Modules 3 and 4.
</details>
4. This module's Armor is only a number. In the real game, "Damage taken will always be deducted from Armor before Health." and "Damage taken while protected by Armor still counts as taking damage for game purposes." ([Armor](https://hearthstone.wiki.gg/wiki/Armor)). Player 0 has 2 Armor and their hero takes 3 damage, with Test Reactor ("Whenever a hero takes damage, gain +1 Attack.") in play. Where in your engine does the absorption go: in the card that deals the damage, in `perform`, or in a trigger? What does the `Damaged` event say?
<details><summary>Discussion</summary>
In `perform`, between "an effect asked for 3 damage" and "the world changed": lower Armor by 2, lower Health by 1, and record one `Damaged` event, so Reactor triggers once. Not in the card, because every source of damage would have to remember to do it. Not in a trigger, because triggers react to events that have already happened, and this changes what happens.
That makes Armor the course's first example of a rule that **rewrites an action before it is performed**, rather than reacting after. Module 4 builds a different rule of that family (how many times a trigger runs), and names the general one: a replacement effect. The questions you just had to answer are the ones every such rule raises. Does the event report the amount asked for or the amount that got through? (The wiki's second quote suggests "it still counts", so the event exists even if Health didn't change.) And if two rewriting rules apply to the same action, which goes first?
The course doesn't build this: no test damages a hero that has Armor. If you build it, decide what `Damaged.amount` means and write that down next to the event type.
</details>
## Further reading
- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017). The most readable worked implementation of a Hearthstone-style reaction tree, in C#. Read `SortActions`: reactions are sorted by `priority` first and `orderOfPlay` only as the tie-breaker, which is where an engine would put Poisonous or Redemption. Also note the loop at the end, the "death reaper", which runs "to take care of any mortally wounded cards" after the root action. That is Module 3.
- Robert Nystrom, [*Event Queue*](https://gameprogrammingpatterns.com/event-queue.html) (*Game Programming Patterns*). The general pattern, and its warnings. Read the sections on feedback loops and on the world state having changed by the time an event is handled: "you have to be careful not to assume the current state of the world reflects how the world was". Your ids from Module 1 are part of the answer.
- Alchyr, [*Action Queue*](https://github.com/Alchyr/BasicMod/wiki/Action-Queue) (BasicMod wiki, Slay the Spire modding). A short practical page about a different game's queue, where the modder chooses where a new action goes: "In a card's use: add to bottom. In an action's update: add to top." Compare it with the depth-first rule: "add to top" is how Slay the Spire makes a reaction happen before the next sibling.
- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): Rule 2 (Queues), the depth-first section, and Rule DH1 on area damage.
- HearthSim, [python-hslog](https://github.com/HearthSim/python-hslog): "a powerful Hearthstone Power.log deserializer". Read the README's Concepts section on the packet tree. It is the closest thing to seeing the real game's resolution as data, and it is a tree of nested blocks.