feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)

A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 11:07:54 +01:00
1 parent 372c99c306
commit 21fb40c6f5
81 files changed
+1880 -528

No files matched your search

+3 -3
View File
@@ -345,16 +345,16 @@ Optional. Each question pushes the design past what this module's tests check. P
<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.
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. The references of Modules 3 and 4 recurse the same way. 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.
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 set each other off, six activations in all, and a player can see that. Work out the six before you check them in Module 5's scenario.
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.
The guard is not part of this module's tests, and the references of Modules 2 to 4 don't have it: a one-off card like the Bomb can't be written in a test until cards are data. It arrives in [Module 5](../05-assemble/lesson.md#the-harness), where `test_05_trigger_does_not_nest_in_itself` registers two Bombs and checks every activation; from Module 5 on, every reference has it. If you add it now, add a test with it.
</details>
+3 -1
View File
@@ -241,7 +241,7 @@ Three decisions in the reference are worth naming, because the rulebook raises t
- **The remembered position is a handle.** A Death Event holds `before`, the id of the nearest surviving minion to the right, and `react` turns it into an index when the Deathrattle resolves. Tokens from the same gap line up left to right in the order they are summoned, which is the order their Deathrattles resolve: order of play. The rulebook says only that the position is "remembered"; it doesn't say how, and it doesn't cover two dying neighbours whose order of play is the reverse of their board order. In that case the reference puts the older minion's token on the left. That is the course's choice.
- **A queued trigger whose source has left play.** In this engine that cannot happen to anything but a Deathrattle. Nothing leaves play mid-Phase, and the Death Creation Step runs only after the outermost Phase, when every Queue has been emptied. Deathrattles are the deliberate exception: their source has always left play by the time they resolve, and they resolve anyway. A minion that is only mortally wounded is still in play and still triggers (Module 2's Acolyte). The course's sources don't say what happens to a queued trigger whose source is removed some other way mid-Phase. No card in this course does that, so the engine doesn't re-check a source before resolving its trigger. That is a course simplification.
- **"A trigger cannot be nested inside itself"** ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The reference has no guard for this yet. No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage *would* recurse forever, and the rule is the cure. Add it when a card needs it.
- **"A trigger cannot be nested inside itself"** ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The references of Modules 3 and 4 have no guard for this; it arrives in [Module 5](../05-assemble/lesson.md#the-harness). No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage *would* recurse forever, and the rule is the cure. Add it when a card needs it: in the course, that is Module 5, where a test can write such a card as data.
> **How the real game differs**
> - The course treats each player action (a `dealDamage`, a `cast`, from Module 4 a `play`) as one outermost Phase, and processes deaths once, after the whole action. In the real game "A Sequence is what begins when a Player Action is taken" and a Sequence is made of Phases, and "death processing is not done in the middle of a Phase, but is done between Phases" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). So one player action can have deaths processed more than once, between its Phases. The course does not model which Phases each action has; none of the course's cards can tell the difference.
@@ -377,6 +377,8 @@ That argument is about the *card set*, not the engine. Break it with one card: T
So termination is a property the designers of a card set have to maintain, or the engine has to enforce. Magic: The Gathering does the second, as a rule of the game: 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)). The course's sources don't say what Hearthstone does. The cheapest engine-side guard is a cap on `round` that fails loudly; a failure with a message is better than a hang, and far better than a silent cut-off that makes one game in a million resolve differently.
The references of Modules 3 and 4 have no cap: given Test Ember, they loop until something external stops them. The cap arrives in [Module 5](../05-assemble/lesson.md#the-harness), where cards are data and a test can write the Ember: more than 64 Death Phases in one Sequence is an error whose message contains `Death Phase limit` (`test_05_death_phase_limit`, with an Ember that also deals 1 damage to all minions). From Module 5 on, every reference has it.
</details>
2. Swap two lines in your Death Creation Step so that it removes each dying minion and creates its Death Event one at a time, in order of play, instead of noting every position first and removing afterwards. Which tests fail?
+61 -22
View File
@@ -100,8 +100,14 @@ A card is a name, a type (`minion` or `spell`), a cost, Attack and Health for mi
- `CardDef` is the card format. In Rust it must implement `serde::Deserialize`, because the tests parse `cards/*.json` with `serde_json`: add `serde = { version = "1", features = ["derive"] }` and `serde_json = "1"` to `Cargo.toml`. In TypeScript the tests import the files with Vite's `import.meta.glob("../cards/*.json")`, which Vitest supports out of the box.
- `newGame` loads every file in `cards/`. Nothing else knows the cards.
- `assemble(base, donor, half)` registers a new card, named `"<base> (Assembled)"`, whose `half` is replaced by the same half of `donor`. Everything else is the base's. It fails with `AssembleError` if the new text equals the base's.
- The chooser makes the choices an `assemble` *effect* needs, in this order: `"base"` (the names of the pool minions the filter allows, sorted), `"donor"` (the pool minions that would change the base's text, sorted), `"half"` (`"top"`, `"bottom"`: those that change it). Until a test sets one, any deterministic default will do.
- `assemble(base, donor, half)` registers a new card, named `"<base> (Assembled)"`, whose `half` is replaced by the same half of `donor`. Everything else is the base's. It fails with `AssembleError` if the new text equals the base's, or if it is not **well-formed**: for a minion, every effect clause belongs to a trigger (none comes before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. Stress it question 1 shows what goes wrong without this rule.
- The chooser makes the choices an `assemble` *effect* needs, in this order: `"base"` (the names of the pool minions the filter allows that have at least one donor, sorted), `"donor"` (the pool minions with a half that gives the base a changed, well-formed text, sorted), `"half"` (`"top"`, `"bottom"`: those that do). Until a test sets one, any deterministic default will do.
Once a test can write any card as data, a test can also write a card that never stops. This module therefore adds three rules about a Sequence:
- **No trigger nests inside itself.** A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. This is Hearthstone's rule as the Advanced rulebook reads it: "It seems that a trigger cannot be nested inside itself." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Module 2's [Stress it](../02-action-queue/lesson.md#stress-it) question 1 asked about it.
- **At most 64 Death Phases.** More than 64 Death Phases in one Sequence is an error: TypeScript throws an `Error` and Rust panics, with a message containing `Death Phase limit`. The rulebook says the Death Phase cycle "repeats forever until no new Deaths occur" (same source); it names no limit. The limit, and its value, are the course's choice. Module 3's [Stress it](../03-death-step/lesson.md#stress-it) question 1 asked about it.
- **Several tokens stand in summon order.** A `summon` effect with `count` 2 or more places its tokens left to right, each immediately right of the one before.
### The tests
@@ -116,6 +122,10 @@ The full statements are in `course/05-assemble/scenarios.md`.
- `test_05_assemble_effect_with_chooser`: cast `Test Workshop` ("Assemble a Battlecry minion. Give all minions in your hand +1/+1.") with an injected chooser. The base options are exactly the Battlecry minions in `cards/`; the new minion and a `Test Filler` already in hand both get +1/+1.
- `test_05_additive_multipliers_stack`: two `Test Chorus` ("Your minions trigger their Deathrattles an additional time.", stacking `additive`) and a Loot Hoarder: three activations. Module 4 built the `additive` branch; this is the first card that uses it, and it needed no code.
- `test_05_summoned_multiplier_counts_for_later_entries` and `test_05_own_aura_misses_own_battlecry`: two timing rules from Module 4 that no Module 4 card could reach. Each test registers a one-off card written as data inside the test (a Deathrattle that summons Baron Rivendare; a minion with a Battlecry and its own Battlecry multiplier). If your Module 4 engine followed the lesson's Aura Update schedule, both pass without new code. See Module 4's [Stress it](../04-interceptors/lesson.md#stress-it), questions 1 and 2.
- `test_05_trigger_does_not_nest_in_itself`: two `Test Bomb`s ("Whenever a minion takes damage, deal 1 damage to all minions."), registered in the test. Damage one: exactly six `Triggered` events, in an order the scenario works out by hand. Work it out yourself before you read it.
- `test_05_death_phase_limit`: `Test Ember` ("Deathrattle: Summon a Test Ember. Deal 1 damage to all minions."). Damage it: 64 Death Phases, then the `Death Phase limit` error.
- `test_05_assemble_rejects_ill_formed_text`: Loot Hoarder with either half of Baron Rivendare fails, and `Test Workshop` offers no aura-only minion (Baron Rivendare, Bolf Ramshield, ...) as a donor for Novice Engineer.
- `test_05_multi_summon_order`: `Test Brood` summons two `Test Filler`s from its Battlecry and two from its Deathrattle; each pair stands left to right in the order it was summoned.
The top-half test:
@@ -196,15 +206,16 @@ Run `cargo test` or `npm test`. The Module 5 tests fail to compile (Rust) or fai
## Build
Make the ten tests pass, and keep Modules 1–4 green at every step. Where code goes:
Make the fourteen tests pass, and keep Modules 1–4 green at every step. Where code goes:
- **No card in code.** Your content directory becomes a loader: it reads `cards/*.json` and hands the definitions to the engine. Delete the card functions from Modules 2–4 once the data versions pass the old tests.
- **The engine interprets.** `src/engine/` gets types that mirror the card format, and one routine that turns an effect plus its context (owner, source, target, position) into the actions you already have. That routine is the only code that knows what `draw` or `dealDamage` means. It names effect kinds and targets, never cards.
- **Assemble is engine code** that works on `text` and `split` and never looks inside a clause beyond comparing it.
- **The `assemble` effect asks; it doesn't roll.** It asks a chooser that lives in your world. Tests inject one through the harness.
- `src/harness` stays thin: `registerCards`, `assemble`, `cost` and `setChooser` each hand one call to the engine.
- **The guards live in the resolution loop.** The nesting guard belongs where your Phase fills its Queue, and the Death Phase limit where your Sequence starts each Death Phase. No card knows about either.
A suggested order: (1) add the card types and parse the course's files in a throwaway test; (2) make the engine build its card library from those definitions and run your old card functions' behaviour through the interpreter, until Modules 1–4 pass with no card functions left; (3) `assemble`; (4) the `assemble` and `buffHand` effects and the chooser.
A suggested order: (1) add the card types and parse the course's files in a throwaway test; (2) make the engine build its card library from those definitions and run your old card functions' behaviour through the interpreter, until Modules 1–4 pass with no card functions left; (3) `assemble`; (4) the `assemble` and `buffHand` effects and the chooser; (5) `wellFormed`, the nesting guard, the Death Phase limit and the summon order.
<details><summary>Hint 1 — nudge</summary>
@@ -222,6 +233,8 @@ Then Assemble: if a card's text is a list, what is "half of a list"?
- **Assemble is list surgery.** `top = text[0:split]`, `bottom = text[split:]`. Replacing the top gives `donor.top + base.bottom` with `split = donor.top.length`; replacing the bottom gives `base.top + donor.bottom` with `split = base.split`. Copy everything else from the base, rename, compare the new text with the base's (structurally), register.
- **The pool** is the minions the engine may offer. The reference uses every registered minion with text that wasn't itself made by Assemble.
- **The chooser** is a function stored in the world: `(kind, options) -> option`. The effect calls it three times. Validate the answer.
- **Well-formed is a walk over the clauses** with one piece of state: no trigger open, a trigger open with no effect yet, or a trigger open with an effect. An effect with no trigger open fails; anything else arriving while a trigger has no effect fails; so does ending there.
- **The nesting guard needs to know what is resolving.** Keep a stack of (minion, trigger) pairs: push when a trigger starts resolving, pop when it ends, and skip a pair that is on the stack when you fill a Queue. If you built `logTree` in Module 2, the stack's height is your nesting depth.
</details>
@@ -245,7 +258,8 @@ runEffects(effects, ctx): // used by react() and by Cast
actions(effect, ctx):
dealDamage(t, n) -> [Damage(targets(t, ctx), n)]
draw(n) -> n x Draw(ctx.owner)
summon(c, n) -> n x Summon(c, ctx.owner, at: right of source, or its death position)
summon(c, n) -> for i in 0..n: Summon(c, ctx.owner, at: p + i)
// p: right of source, or its death position; right end for a spell
assemble(f) -> [Assemble(ctx.owner, f)]
buffHand(a, h) -> [Buff(id, a, h) for id in minions in ctx.owner's hand]
...
@@ -254,14 +268,30 @@ assemble(base, donor, half):
top = half == top ? donor.text[0:donor.split] : base.text[0:base.split]
bottom = half == bottom ? donor.text[donor.split:] : base.text[base.split:]
if top + bottom == base.text: error AssembleError
if not wellFormed(top + bottom): error AssembleError
return base with name = base.name + " (Assembled)", text = top + bottom, split = len(top)
perform(Assemble(player, filter)):
pool = pool minions, sorted by name
base = choose("base", [m in pool where filter.trigger is none or m has that trigger])
donor = choose("donor", [m in pool where m != base and some half changes base's text])
half = choose("half", [h in (top, bottom) where it changes base's text])
fits(b, d, h) = assemble(b, d, h) does not fail
donors(b) = [m in pool where m != b and fits(b, m, h) for some half h]
base = choose("base", [m in pool where (filter.trigger is none or m has that trigger)
and donors(m) is not empty])
donor = choose("donor", donors(base))
half = choose("half", [h in (top, bottom) where fits(base, donor, h)])
register assemble(base, donor, half); create it in player's hand
react(events): // the queue fill gains one condition
for each event, listener, trigger that responds:
if (listener, trigger) is not on the resolving stack: queue it
for each entry: push it on the stack; resolve it; pop it
runSequence(action):
perform(action); round = 0
while deaths:
round += 1
if round > 64: error "Death Phase limit"
...
```
</details>
@@ -270,7 +300,10 @@ When your tests pass, compare with the reference engine in `course/05-assemble/r
Decisions in the reference that the tests don't force:
- **Where a summon goes.** Until now only Deathrattles summoned, at the dead minion's remembered position (Module 3). With Assemble, "Battlecry: Summon a 2/1 Damaged Golem." exists, and its minion is still on the board. The reference summons to the right of a source still in play, at the remembered position for a source that has left, and at the right end for a spell. The course's research notes don't cover Battlecry summons, so treat this as the course's choice.
- **Where a summon goes.** Until now only Deathrattles summoned, at the dead minion's remembered position (Module 3). With Assemble, "Battlecry: Summon a 2/1 Damaged Golem." exists, and its minion is still on the board. The reference summons to the right of a source still in play, at the remembered position for a source that has left, and at the right end for a spell. Several tokens from one effect go left to right in summon order. The course's research notes don't cover Battlecry summons, so treat this as the course's choice.
- **What "the same trigger" means** for the nesting guard. The reference keeps a stack of (minion, index of the trigger in its card's text) pairs in the world, beside the log's depth (`openBlock(source, trigger)`, `isResolving` in `world.ts`; `open_block`, `is_resolving` in `world.rs`). The stack's height is the log tree's depth. A trigger activated twice by a multiplier is two activations of one Queue entry, one after the other, not one inside the other, so the guard doesn't stop the second.
- **The Death Phase limit is checked before a Death Phase starts,** between Death Phases, where no trigger is resolving. So the error leaves no block open: in TypeScript, which has no `finally` in the reference's loop, the depth is already 0 when it throws, and the test checks that the next event is logged at depth 0. The world is otherwise left as it was when the limit was reached (an Ember on the board, mortally wounded). The value 64 is the course's choice: large enough for any Sequence of the course's cards, small enough to fail at once.
- **Well-formed for a spell.** The rule is written for minions, the only cards Assemble makes; the reference passes any spell. It is a little stricter than "no effect before the first trigger": an effect after an aura or keyword that follows a finished trigger (`[trigger, effect, aura, effect]`) also fails, because nothing would ever run it. No card in the course has that shape.
- **The pool** is every registered minion with text, minus Assembled cards. That includes the fictional `Test` cards. The real pool is curated: see below.
- **Two Assembles of the same base with different donors** would both be named "Harvest Golem (Assembled)", and registering the second would silently rewrite the first. The reference names the second "Harvest Golem (Assembled 2)". The harness contract fixes the first name, and `test_05_assemble_never_rewrites_an_earlier_card` fixes only that the second differs. "Different" means anything the engine reads: the text, the `split`, or a base re-registered with a new cost. The two reference engines once disagreed here, because one of them compared only the text.
- **The default chooser** takes the first option. A real game would ask the player or roll; the tests never rely on the default.
@@ -337,12 +370,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -360,7 +398,8 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
```
@@ -369,7 +408,7 @@ Three things follow from making behaviour data:
- **New cards are files.** `Test Chorus` is the first card to use Module 4's `additive` stacking. It is one short JSON file; nothing in the engine changed for it.
- **The vocabulary is closed, and that is a feature.** A card can only do what the interpreter's `match` can do. Nystrom: "The bytecode can't do anything malicious or reach out into weird parts of the game engine because we've only defined a few instructions that touch the rest of the game." A card file can't name a function, open a file or loop forever. When a new card needs a new verb (this module added `assemble` and `buffHand`), that is an engine change, made once, and every later card may use it.
- **Behaviour can be inspected and transformed.** Assemble compares two halves for equality and concatenates lists. The must-change rule is `text == base.text`. None of this is possible with closures.
- **Behaviour can be inspected and transformed.** Assemble compares two halves for equality and concatenates lists. The must-change rule is `text == base.text`, and the well-formed rule is a ten-line walk over the clauses. None of this is possible with closures.
The Argentum engine (Magic: The Gathering, Kotlin) makes the same commitment in its [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md). §1.1: "Cards are pure, serializable data structures with zero execution logic." §1.2 goes one level further than the course: "Numbers are represented as inspectable AST nodes, not integers or lambdas." The course's `amount` is a plain integer; an "X damage, where X is the number of cards in your hand" card would need §1.2.
@@ -427,14 +466,14 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
1. `assemble("Loot Hoarder", "Baron Rivendare", "top")` and `assemble("Loot Hoarder", "Baron Rivendare", "bottom")`. Baron's `split` is 0: his top half is empty and his bottom half is the aura. Write out the `text` of each result, then say what each card should *do*. Then summon each one in your engine, destroy it, and read the log.
1. `assemble("Loot Hoarder", "Baron Rivendare", "top")` and `assemble("Loot Hoarder", "Baron Rivendare", "bottom")`. Baron's `split` is 0: his top half is empty and his bottom half is the aura. Both fail in this module (`test_05_assemble_rejects_ill_formed_text`). Write out the `text` of each result, then say what each card would *do* if `assemble` accepted it. If you like, comment out your `wellFormed` check, summon each one, destroy it, and read the log.
<details><summary>Discussion</summary>
- **Top:** `[] + [effect: draw]`. A minion whose whole text is "Draw a card." with no trigger in front of it. In the reference the clause compiles into the "effects before any trigger" list, which only a spell ever runs. The card does nothing, ever, and nothing reports that.
- **Bottom:** `[trigger: deathrattle] + [aura: Deathrattles ×2]`. It reads "Deathrattle: Your minions trigger their Deathrattles twice." In the reference the trigger gets no effects, so on death the log shows a `Triggered(deathrattle)` followed by nothing; and the aura is in force the whole time the minion is in play, exactly like Baron's, because an aura clause isn't attached to the trigger before it.
- **Top:** `[] + [effect: draw]`. A minion whose whole text is "Draw a card." with no trigger in front of it. The clause would compile into the "effects before any trigger" list, which only a spell ever runs. The card would do nothing, ever, and nothing would report that.
- **Bottom:** `[trigger: deathrattle] + [aura: Deathrattles ×2]`. It reads "Deathrattle: Your minions trigger their Deathrattles twice." The trigger would get no effects, so on death the log would show a `Triggered(deathrattle)` followed by nothing; and the aura would be in force the whole time the minion is in play, exactly like Baron's, because an aura clause isn't attached to the trigger before it.
Neither result is what the text says to a player, and `assemble` accepted both. The function is closed over *lists of clauses*, but a card's text has a grammar (an effect belongs to a trigger; a trigger needs an effect; an aura stands alone), and cutting two well-formed texts in half and swapping can leave that grammar. `text == base.text` is the only check, and it is about a different question.
Neither result is what the text says to a player. Until this revision of the course, the reference's `assemble` accepted both. The function is closed over *lists of clauses*, but a card's text has a grammar (an effect belongs to a trigger; a trigger needs an effect; an aura stands alone), and cutting two well-formed texts in half and swapping can leave that grammar. `text == base.text` is the only check, and it is about a different question.
A design has three honest ways out:
@@ -442,7 +481,7 @@ A design has three honest ways out:
- **Check the result.** Write `wellFormed(text)` and have the `assemble` effect offer only donors and halves whose result passes. The rule lives in one function, next to the must-change rule. Alexis King's [*Parse, don't validate*](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) argues for going one step further: make the compiled form unable to represent a trigger without effects, so the check can't be skipped.
- **Give every list a meaning.** Decide that an aura after a trigger is granted when the trigger fires, and that a bare effect on a minion is a Battlecry. Now nothing is ill-formed, and you have designed several new mechanics by accident.
The course takes none of them: its pool is small and its tests only assemble pairs that fit. Write `wellFormed` for the course's four clause kinds; it is about ten lines. Then count how many of the base × donor × half combinations in `cards/` it rejects.
The reference takes the second, from this module on: `wellFormed` / `well_formed` in `card`, called by `assemble`, so the effect's options leave out every combination that would fail. It doesn't take King's further step: the compiled card can still hold a trigger without effects, and only the check at the boundary keeps one out. Every card the course ships passes the same rule (`tools/test/well-formed.test.mjs`). Count how many of the base × donor × half combinations in `cards/` change the text, and how many of those your `wellFormed` rejects. (With the course's cards at this module: 386 change it, and 200 of those are ill-formed.) More than half: the pool is not curated, and the check does the curation.
</details>
@@ -452,7 +491,7 @@ The course takes none of them: its pool is small and its tests only assemble pai
A dozen cases cover the course: one phrase per trigger ("Deathrattle:", "Whenever this minion takes damage,"), one per effect, one per target, and the joining punctuation. The test is a loop over the catalog.
It is worth doing for what it proves. The card file is now the single source of both what the card *does* and what it *says*, so the two can't drift apart. And Assemble gets its rules text for free: render the assembled clause list. In the real game the assembled minion's "text changes"; with behaviour as data, that is the same operation as changing what it does. Try `render` on the two cards from question 1. A renderer that can't produce a sensible sentence is a second detector for ill-formed text.
It is worth doing for what it proves. The card file is now the single source of both what the card *does* and what it *says*, so the two can't drift apart. And Assemble gets its rules text for free: render the assembled clause list. In the real game the assembled minion's "text changes"; with behaviour as data, that is the same operation as changing what it does. Try `render` on the two texts from question 1. A renderer that can't produce a sensible sentence is a second detector for ill-formed text.
You will find cards where the catalog's sentence and your rendering can't match word for word (Harvest Golem's "Summon a 2/1 Damaged Golem." needs the token's stats). Decide whether `render` looks the token up or the test allows a difference, and note which. That is the gap Argentum's "inspectable AST nodes" in Further reading are there to close.
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
mod assemble;
mod aura;
@@ -29,11 +31,11 @@ mod event;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,9 +18,11 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
export { AssembleError, assemble, registerAssembled, type AssembleFailure, type Half } from "./assemble";
export {
CardLibrary,
triggerLabel,
@@ -33,7 +35,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
+51 -1
View File
@@ -4,7 +4,7 @@ Each heading is a test name. The Rust test lives in `tests/rust/test_05.rs` and
From this module, every card is data: one JSON file per card in the workspace's `cards/` directory (copied from `course/cards/` by `start-module`), in the format of `course/schema/card.schema.json`. `newGame` loads all of them. The tests read the same files: the Rust tests from `<crate root>/cards` and parse each with `serde_json` into the harness's `CardDef`; the TypeScript tests import them with Vite's `import.meta.glob("../cards/*.json")`.
A card's `text` is a list of clauses and `split` divides it in two: the top half is `text[0:split]`, the bottom half `text[split:]`. Assembling a base card with a donor card replaces one half of the base's text with the same half of the donor's. Everything else (name stem, cost, Attack, Health, type) comes from the base. The new card is named `"<base> (Assembled)"`. If the new text equals the base's text, `assemble` fails with `AssembleError`.
A card's `text` is a list of clauses and `split` divides it in two: the top half is `text[0:split]`, the bottom half `text[split:]`. Assembling a base card with a donor card replaces one half of the base's text with the same half of the donor's. Everything else (name stem, cost, Attack, Health, type) comes from the base. The new card is named `"<base> (Assembled)"`. If the new text equals the base's text, or is not well-formed, `assemble` fails with `AssembleError`. Well-formed (for a minion): every effect clause belongs to a trigger (none comes before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. The `assemble` effect offers only bases, donors and halves that give a changed, well-formed text.
Cards used (text from `course/catalog.json`; the clauses from `course/cards/`, written `[top | bottom]`):
@@ -13,6 +13,7 @@ Cards used (text from `course/catalog.json`; the clauses from `course/cards/`, w
- `Loot Hoarder`, 2-mana 2/1: "Deathrattle: Draw a card." `[trigger deathrattle | effect draw 1]`, split 1.
- `Novice Engineer`, 2-mana 1/1: "Battlecry: Draw a card." `[trigger battlecry | effect draw 1]`, split 1.
- `Baron Rivendare`, 4-mana 1/7: "Your minions trigger their Deathrattles twice." One aura clause, stacking `max`.
- `Bolf Ramshield`, 6-mana 3/9: "Whenever your hero takes damage, this minion takes it instead." One aura clause (`damageRedirect`), split 0.
- `Test Filler` (fictional), 1-mana 1/1, no text.
- `Test Sponge` (fictional), 1-mana 1/3, no text.
- `Test Chorus` (fictional), 1-mana 1/5: "Your minions trigger their Deathrattles an additional time." One aura clause: `triggerMultiplier` on `deathrattle`, `times` 2, stacking `additive`.
@@ -118,3 +119,52 @@ The test registers a one-off card with `registerCards`, written as data in the t
- Assertions 1: the log's `Triggered` events are exactly one, `Triggered{source: B1, trigger: "battlecry"}`, and `hand(0)` has 2 cards (B2 and the card drawn). Playing is not summoning: no Aura Update runs when B1 enters the board, so its own multiplier is not in the snapshot when its Battlecry resolves. An engine that scans the board every time it counts activations triggers the Battlecry twice here.
- Action 2: `clearLog()`, then `play(B2)`.
- Assertions 2: the log's `Triggered` events are exactly two, both `Triggered{source: B2, trigger: "battlecry"}`, and `hand(0)` has 3 cards. B1's aura joined the snapshot at the end of the first play's outermost Phase.
### test_05_trigger_does_not_nest_in_itself
The test registers a one-off card with `registerCards`, written as data in the test: `Test Bomb`, a 1/50 minion, "Whenever a minion takes damage, deal 1 damage to all minions." (`split` 1; an `anyMinionDamaged` trigger clause, then a `dealDamage` effect clause, target `allMinions`, amount 1). Without a guard, its trigger queues itself inside itself forever. The rule (Hearthstone's Advanced rulebook, see Module 2's Stress it question 1): a trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving.
- Setup: `newGame()`. Register `Test Bomb`. Summon two (ids B1, then B2). Call `clearLog()`.
- Action: `dealDamage(B1, 1)`.
- Worked out by hand. Each Damage to a minion queues the trigger of every Bomb that is not resolving, once per Damage event. "Resolving" below is the set of Bomb triggers on the stack at that moment.
1. `Damaged{target: B1}`. Queue: B1, B2. Lock.
2. B1 resolves (resolving: B1): `Triggered{source: B1}`, then B1 and B2 each take 1. Two Damage events; B1's trigger is resolving, so each queues only B2. Queue: B2, B2.
1. B2 resolves (resolving: B1, B2): `Triggered{source: B2}`, both Bombs take 1. Both triggers are resolving: nothing is queued.
2. B2 resolves again, the second entry (resolving: B1, B2): `Triggered{source: B2}`, both take 1, nothing is queued.
3. B2 resolves, the entry from step 1 (resolving: B2): `Triggered{source: B2}`, both take 1. Each Damage event queues B1. Queue: B1, B1.
1. B1 resolves (resolving: B2, B1): `Triggered{source: B1}`, both take 1, nothing is queued.
2. B1 resolves again (resolving: B2, B1): `Triggered{source: B1}`, both take 1, nothing is queued.
- Assertions:
- The log's `Triggered` events are exactly, in this order: `Triggered{source: B1, trigger: "damaged"}`, `Triggered{source: B2, trigger: "damaged"}` three times, then `Triggered{source: B1, trigger: "damaged"}` twice. Six in all.
- The log has exactly 13 `Damaged` events: the first, and two for each of the six activations.
- `health(B1)` is 43 (1 + 6 damage) and `health(B2)` is 44 (6 damage).
### test_05_death_phase_limit
The test registers a one-off card with `registerCards`, written as data in the test: `Test Ember`, a 1/1 minion, "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." (`split` 1; a `deathrattle` trigger clause, a `summon` effect clause for `Test Ember`, count 1, then a `dealDamage` effect clause, target `allMinions`, amount 1). Each Death Phase summons a new Ember and kills it, so the Sequence never ends on its own. More than 64 Death Phases in one Sequence is an error: TypeScript throws an `Error` and Rust panics, both with a message that contains `Death Phase limit`.
- Setup: `newGame()`. Register `Test Ember`. Summon one (id E). Call `clearLog()`.
- Action: `dealDamage(E, 1)`. TypeScript: expect it to throw. Rust: run it inside `std::panic::catch_unwind` and expect a panic.
- Assertions:
- The error (TypeScript) or panic message (Rust) contains `Death Phase limit`.
- The log has exactly 64 `DeathPhase` events, the last `DeathPhase{round: 64}`. Death Phases 1 to 64 ran; the 65th was refused before it started.
- Then `clearLog()` and summon a `Test Filler` (id F): `logTree()` is exactly `Summoned{id: F}` and a newline. The error comes between Death Phases, where no trigger is resolving, so it leaves no block open and the next event is at depth 0.
### test_05_assemble_rejects_ill_formed_text
- Action 1: `assemble("Loot Hoarder", "Baron Rivendare", "top")`, then `assemble("Loot Hoarder", "Baron Rivendare", "bottom")`.
- Assertions 1: each fails with `AssembleError`. Baron's `split` is 0. The top result is `[effect draw 1]`: an effect with no trigger. The bottom result is `[trigger deathrattle | aura]`: a trigger with no effect. Both change the text, and neither is well-formed.
- Action 2: set a chooser that records every call and answers `"Novice Engineer"` for `"base"`, `"Loot Hoarder"` for `"donor"` and `"top"` for `"half"`. Player 0 casts `Test Workshop`.
- Assertions 2:
- The minion files in `cards/` whose text is one or more aura clauses and nothing else include `Baron Rivendare` and `Bolf Ramshield`.
- The `"donor"` options include `"Loot Hoarder"` and none of those aura-only minions. With Novice Engineer `[trigger battlecry | effect draw 1]` as the base, an aura-only donor's top half (empty) leaves `[effect draw 1]`, and its bottom half leaves `[trigger battlecry | aura]`.
### test_05_multi_summon_order
The test registers a one-off card with `registerCards`, written as data in the test: `Test Brood`, a 1/1 minion, "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers." (`split` 2; a `battlecry` trigger clause, a `summon` effect clause for `Test Filler` with count 2, a `deathrattle` trigger clause, then the same `summon` effect clause). A `summon` effect with `count` greater than 1 places its tokens left to right in summon order: each token goes immediately right of the one before.
- Setup: `newGame()`. Register `Test Brood`. Summon `Test Filler` (id F1). Add `Test Brood` to player 0's hand (id B). Call `clearLog()`.
- Action 1: `play(B)`.
- Assertions 1: the log's `Summoned` events other than B's name two new ids, T1 then T2, with T1 < T2. `board(0)` is `[F1, B, T1, T2]`. A summon from a minion in play goes to its right, and the second token to the right of the first.
- Action 2: summon `Test Filler` (id F2), `clearLog()`, then `destroy(B)`.
- Assertions 2: the log's `Summoned` events name two new ids, T3 then T4, with T3 < T4. `board(0)` is `[F1, T3, T4, T1, T2, F2]`. The Deathrattle summons where B stood, left of T1 (Module 3), the first token first.
+143
View File
@@ -418,3 +418,146 @@ fn test_05_own_aura_misses_own_battlecry() {
);
assert_eq!(g.hand(0).len(), 3);
}
/// "Whenever a minion takes damage, deal 1 damage to all minions."
const TEST_BOMB: &str = r#"{ "name": "Test Bomb", "type": "minion", "cost": 1, "attack": 1, "health": 50, "split": 1, "text": [{ "kind": "trigger", "on": "anyMinionDamaged" }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#;
#[test]
fn test_05_trigger_does_not_nest_in_itself() {
let mut g = new_game(GameOpts::default());
g.register_cards(vec![card(TEST_BOMB)]);
let b1 = g.summon("Test Bomb", 0);
let b2 = g.summon("Test Bomb", 0);
g.clear_log();
g.deal_damage(b1, 1);
// A trigger is never queued while that same minion's same trigger is resolving.
let expected: Vec<GameEvent> = [b1, b2, b2, b2, b1, b1]
.iter()
.map(|&source| triggered(source, "damaged"))
.collect();
assert_eq!(triggers(&g.log()), expected);
let damage = g
.log()
.iter()
.filter(|e| matches!(e, GameEvent::Damaged { .. }))
.count();
assert_eq!(damage, 13);
assert_eq!(g.health(b1), 43);
assert_eq!(g.health(b2), 44);
}
/// "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions."
const TEST_EMBER: &str = r#"{ "name": "Test Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Ember", "count": 1 } }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#;
#[test]
fn test_05_death_phase_limit() {
let mut g = new_game(GameOpts::default());
g.register_cards(vec![card(TEST_EMBER)]);
let e = g.summon("Test Ember", 0);
g.clear_log();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.deal_damage(e, 1)));
let payload = result.expect_err("more than 64 Death Phases must panic");
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
assert!(message.contains("Death Phase limit"), "{message}");
// Death Phases 1 to 64 ran; the 65th was refused before it started.
let phases: Vec<GameEvent> = g
.log()
.into_iter()
.filter(|e| matches!(e, GameEvent::DeathPhase { .. }))
.collect();
assert_eq!(phases.len(), 64);
assert_eq!(phases[63], GameEvent::DeathPhase { round: 64 });
// The panic is raised between Death Phases, where no trigger is resolving: the next event is at depth 0.
g.clear_log();
let f = g.summon("Test Filler", 0);
assert_eq!(g.log_tree(), format!("Summoned{{id: {f}}}\n"));
}
#[test]
fn test_05_assemble_rejects_ill_formed_text() {
let mut g = new_game(GameOpts::default());
// Top: "Draw a card." with no trigger. Bottom: a Deathrattle with no effect, then Baron's aura.
let top = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Top);
let bottom = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Bottom);
assert!(top.is_err(), "{top:?}");
assert!(bottom.is_err(), "{bottom:?}");
let calls: Rc<RefCell<Vec<(String, Vec<String>)>>> = Rc::default();
let record = Rc::clone(&calls);
g.set_chooser(move |kind: &str, options: &[String]| {
record
.borrow_mut()
.push((kind.to_string(), options.to_vec()));
match kind {
"base" => "Novice Engineer",
"donor" => "Loot Hoarder",
_ => "top",
}
.to_string()
});
g.cast("Test Workshop", 0, None);
// A minion whose whole text is one aura has no half that fits around a trigger and its effect.
let aura_only: Vec<String> = card_files()
.into_iter()
.filter(|(raw, _)| raw["type"] == "minion")
.filter(|(raw, _)| {
let text = raw["text"].as_array().unwrap();
!text.is_empty() && text.iter().all(|c| c["kind"] == "aura")
})
.map(|(raw, _)| raw["name"].as_str().unwrap().to_string())
.collect();
assert!(aura_only.contains(&"Baron Rivendare".to_string()));
assert!(aura_only.contains(&"Bolf Ramshield".to_string()));
let donors = calls.borrow()[1].1.clone();
assert!(donors.contains(&"Loot Hoarder".to_string()));
for name in &aura_only {
assert!(!donors.contains(name), "{name} offered: {donors:?}");
}
}
/// "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers."
const TEST_BROOD: &str = r#"{ "name": "Test Brood", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }, { "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }] }"#;
#[test]
fn test_05_multi_summon_order() {
let mut g = new_game(GameOpts::default());
g.register_cards(vec![card(TEST_BROOD)]);
let f1 = g.summon("Test Filler", 0);
let b = g.add_to_hand("Test Brood", 0);
g.clear_log();
g.play(b);
// Ids are issued in summon order: the first token is the smaller id, and it stands further left.
let summoned = |log: Vec<GameEvent>| -> Vec<Id> {
log.into_iter()
.filter_map(|e| match e {
GameEvent::Summoned { id } if id != b => Some(id),
_ => None,
})
.collect()
};
let tokens = summoned(g.log());
let (t1, t2) = (tokens[0], tokens[1]);
assert!(t1 < t2);
assert_eq!(g.board(0), vec![f1, b, t1, t2]);
let f2 = g.summon("Test Filler", 0);
g.clear_log();
g.destroy(b);
let tokens = summoned(g.log());
let (t3, t4) = (tokens[0], tokens[1]);
assert!(t3 < t4);
assert_eq!(g.board(0), vec![f1, t3, t4, t1, t2, f2]);
}
@@ -277,3 +277,97 @@ test("test_05_own_aura_misses_own_battlecry", () => {
]);
expect(g.hand(0)).toHaveLength(3);
});
/** "Whenever a minion takes damage, deal 1 damage to all minions." A one-off card, written as data in the test. */
const TEST_BOMB = { "name": "Test Bomb", "type": "minion", "cost": 1, "attack": 1, "health": 50, "split": 1, "text": [{ "kind": "trigger", "on": "anyMinionDamaged" }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] } as CardDef;
test("test_05_trigger_does_not_nest_in_itself", () => {
const g = newGame();
g.registerCards([TEST_BOMB]);
const b1 = g.summon("Test Bomb", 0);
const b2 = g.summon("Test Bomb", 0);
g.clearLog();
g.dealDamage(b1, 1);
// A trigger is never queued while that same minion's same trigger is resolving.
expect(g.log().filter((e) => e.type === "Triggered")).toEqual(
[b1, b2, b2, b2, b1, b1].map((source) => ({ type: "Triggered", source, trigger: "damaged" })),
);
expect(g.log().filter((e) => e.type === "Damaged")).toHaveLength(13);
expect(g.health(b1)).toBe(43);
expect(g.health(b2)).toBe(44);
});
/** "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." A one-off card, written as data in the test. */
const TEST_EMBER = { "name": "Test Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Ember", "count": 1 } }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] } as CardDef;
test("test_05_death_phase_limit", () => {
const g = newGame();
g.registerCards([TEST_EMBER]);
const e = g.summon("Test Ember", 0);
g.clearLog();
expect(() => g.dealDamage(e, 1)).toThrow("Death Phase limit");
// Death Phases 1 to 64 ran; the 65th was refused before it started.
const phases = g.log().filter((event) => event.type === "DeathPhase");
expect(phases).toHaveLength(64);
expect(phases[63]).toEqual({ type: "DeathPhase", round: 64 });
// The error is raised between Death Phases, where no trigger is resolving: the next event is at depth 0.
g.clearLog();
const f = g.summon("Test Filler", 0);
expect(g.logTree()).toBe(`Summoned{id: ${f}}\n`);
});
test("test_05_assemble_rejects_ill_formed_text", () => {
const g = newGame();
// Top: "Draw a card." with no trigger. Bottom: a Deathrattle with no effect, then Baron's aura.
expect(() => g.assemble("Loot Hoarder", "Baron Rivendare", "top")).toThrow(AssembleError);
expect(() => g.assemble("Loot Hoarder", "Baron Rivendare", "bottom")).toThrow(AssembleError);
const calls: [string, string[]][] = [];
g.setChooser((kind, options) => {
calls.push([kind, [...options]]);
return kind === "base" ? "Novice Engineer" : kind === "donor" ? "Loot Hoarder" : "top";
});
g.cast("Test Workshop", 0);
// A minion whose whole text is one aura has no half that fits around a trigger and its effect.
const auraOnly = cardFiles()
.filter((c) => c.type === "minion" && c.text.length > 0 && c.text.every((clause) => clause.kind === "aura"))
.map((c) => c.name);
expect(auraOnly).toContain("Baron Rivendare");
expect(auraOnly).toContain("Bolf Ramshield");
const [, donors] = calls[1];
expect(donors).toContain("Loot Hoarder");
for (const name of auraOnly) expect(donors).not.toContain(name);
});
/** "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers." A one-off card, written as data in the test. */
const TEST_BROOD = { "name": "Test Brood", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }, { "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }] } as CardDef;
test("test_05_multi_summon_order", () => {
const g = newGame();
g.registerCards([TEST_BROOD]);
const f1 = g.summon("Test Filler", 0);
const b = g.addToHand("Test Brood", 0);
g.clearLog();
g.play(b);
// Ids are issued in summon order: the first token is the smaller id, and it stands further left.
const summoned = () => g.log().flatMap((e) => (e.type === "Summoned" && e.id !== b ? [e.id] : []));
const [t1, t2] = summoned();
expect(t1).toBeLessThan(t2);
expect(g.board(0)).toEqual([f1, b, t1, t2]);
const f2 = g.summon("Test Filler", 0);
g.clearLog();
g.destroy(b);
const [t3, t4] = summoned();
expect(t3).toBeLessThan(t4);
expect(g.board(0)).toEqual([f1, t3, t4, t1, t2, f2]);
});
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -34,11 +36,11 @@ pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -38,7 +40,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -35,11 +37,11 @@ pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -39,7 +41,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -36,11 +38,11 @@ pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -40,7 +42,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -36,11 +38,11 @@ pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -40,7 +42,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -265,6 +265,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -44,15 +46,16 @@ mod queue;
mod spellgrid;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use aura::aura_update_other;
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{
run_sequence, summon, Action, Ctx, DamageStep, EndOfPhaseStep, DAMAGE_STEPS, END_OF_PHASE,
run_sequence, summon, Action, Ctx, DamageStep, EndOfPhaseStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT,
END_OF_PHASE,
};
pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -21,6 +21,11 @@
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`,
//! read from the world, as Part II's turn is a list of phases. SpellGrid's tests run
//! the same Sequence with the list reversed, to show what the order decides.
@@ -145,6 +150,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -154,6 +163,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -297,18 +311,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -324,7 +347,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -90,8 +90,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
/// Module 10: auras that no minion carries, for every player's minions. SpellGrid
@@ -113,7 +115,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
rule_auras: Vec::new(),
end_of_phase: END_OF_PHASE.to_vec(),
@@ -329,17 +331,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
@@ -8,20 +8,25 @@
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import { hasTrigger, wellFormed, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, def: CardDef): string {
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
* (filtered), a donor, and the half to replace. Only combinations that change the
* base's text and leave it well-formed are offered: a donor is offered if one of its
* halves gives such a text, and a base if it has such a donor. The Assembled minion
* goes to the owner's hand. Real Hearthstone picks the base at random and offers two
* donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -150,6 +150,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* Does `text` mean what it says? For a minion, every effect clause belongs to a
* trigger (none comes before the first trigger, or after an aura or keyword), and
* every trigger has at least one effect before the next trigger, aura, keyword or
* the end. The rule is for minions (Assemble makes only minions): a spell passes.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -18,7 +18,9 @@
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
// builds a new card by swapping half of one card's text for half of another's, and
// offers only results that are well-formed. A Sequence is bounded: a trigger never
// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
@@ -33,7 +35,7 @@
// the end-of-Phase steps as a list (`END_OF_PHASE`); Part II gains a `Destroy` hook.
export * as puzzle from "./puzzle";
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
export { AssembleError, assemble, registerAssembled, type AssembleFailure, type Half } from "./assemble";
export {
CardLibrary,
triggerLabel,
@@ -46,12 +48,14 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { auraUpdateOther } from "./aura";
export {
Ctx,
DAMAGE_STEPS,
DEATH_PHASE_LIMIT,
END_OF_PHASE,
runSequence,
summon,
@@ -21,6 +21,11 @@
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//
// Module 5, robustness: once a test can write any card as data, one card can make a
// Sequence endless. A trigger is never queued while that same trigger of that same
// minion is resolving (the nesting guard in `react`), and a Sequence that needs more
// than `DEATH_PHASE_LIMIT` Death Phases is an error.
//
// Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`,
// read from the world, as Part II's turn is a list of phases. SpellGrid's tests run
// the same Sequence with the list reversed, to show what the order decides.
@@ -111,6 +116,12 @@ export class Ctx {
}
}
/**
* The most Death Phases one Sequence may have. One more is an error, not a cut-off:
* a Sequence that stopped early would resolve differently without saying so.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -118,6 +129,10 @@ export function runSequence(world: World, action: Action): void {
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -245,16 +260,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
@@ -265,7 +284,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -53,8 +53,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* The triggers resolving right now, one inside another, outermost first: each is a
* minion and the index of the trigger in its card's `triggers`. How many there are
* is the nesting depth of the log tree.
*/
private resolving: [EntityId, number][] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
@@ -252,17 +256,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */
+1 -1
View File
@@ -34,7 +34,7 @@ Write your answers down before you open the discussion.
1. Lower than you'd like, and you can measure it. That is mutation testing, below.
2. You can check that two *related* boards give related answers (the same board shifted, or with x and y swapped), and you can check facts that hold for every board. Those are metamorphic relations and invariants.
3. It proves that the level is solvable and that one particular input sequence wins. It doesn't prove that the solution needs the idea the level was built around, or that there is no shorter way. A search does.
4. The engine: yes, with the right two cards (Modules 2 and 3). A level: there is no general way to tell for the real games, which is a theorem, and the last section says what follows from it in practice.
4. The engine: before Module 5, yes, with the right two cards (Modules 2 and 3). Module 5's nesting guard and Death Phase limit stop both of those, but not every card: a damage trigger that summons a new copy of its own minion gets a fresh (minion, trigger) pair every time, so the guard never fires. A level: there is no general way to tell for the real games, which is a theorem, and the last section says what follows from it in practice.
</details>
+5 -1
View File
@@ -12,7 +12,7 @@ Each module adds functions; `start-module` prints the line for the module you st
- M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`; `logTree(): string` (the log as an indented tree, see below).
- M3: `destroy(target: Id): void` (marks pending-destroy, one Sequence).
- M4: no new functions; `play(handId)` (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (`Summoned{id}`), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). The M4 tests also use `Baron Rivendare`, `Novice Engineer`, `Bolf Ramshield` and `Twisting Nether` (the M3 Expansion Drop card). New event `ArmorLost{player, amount}` (see `GameEvent`).
- M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `"<base> (Assembled)"`; throws `AssembleError` if text unchanged); `cost(id): number`; `setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void` (makes Assemble's choices). `CardDef` is the card format of `course/schema/card.schema.json`; `newGame` loads every card file in the workspace's `cards/` directory.
- M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `"<base> (Assembled)"`; throws `AssembleError` if the text is unchanged or not well-formed); `cost(id): number`; `setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void` (makes Assemble's choices). `CardDef` is the card format of `course/schema/card.schema.json`; `newGame` loads every card file in the workspace's `cards/` directory.
### Side by side (Part I)
@@ -42,6 +42,10 @@ Each module adds functions; `start-module` prints the line for the module you st
From Module 5, Rust's `CardDef` must implement `serde::Deserialize` for the card format: the tests parse `cards/*.json` with `serde_json` (add `serde` with the `derive` feature and `serde_json` to `Cargo.toml`). The TypeScript tests import the files with Vite's `import.meta.glob`.
From Module 5, Assemble's result must be well-formed. For a minion: every effect clause belongs to a trigger (none before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. `assemble` fails with `AssembleError` for a result that is unchanged or ill-formed, and the chooser of an `assemble` effect is offered only bases, donors and halves that give a changed, well-formed result.
From Module 5, a Sequence is bounded. A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. More than 64 Death Phases in one Sequence is an error: the call that started the Sequence throws an `Error` (TypeScript) or panics (Rust), with a message containing `Death Phase limit`. A `summon` effect with `count` greater than 1 places its tokens left to right in summon order.
### `GameEvent`
Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, trigger}` (`trigger`: `"deathrattle"|"battlecry"|"damaged"|...`), `Destroyed{id}` (removed in a Death Creation Step), `DeathPhase{round}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `ArmorLost{player, amount}` (from M4), `StatsChanged{id, attack, health}`, `SpellCast{card}`.
+64
View File
@@ -0,0 +1,64 @@
// Every card the course ships is well-formed, by the rule the reference engines apply to
// Assemble (Module 5: `wellFormed` in card.ts, `well_formed` in card.rs), restated here
// against the JSON so that a new card file is checked without building an engine.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { courseRoot, moduleDirs } from "../lib.mjs";
/**
* For a minion, every effect clause belongs to a trigger (none before the first trigger,
* or after an aura or keyword), and every trigger has at least one effect before the next
* trigger, aura, keyword or the end. A spell passes.
*/
function wellFormed(text, type) {
if (type !== "minion") return true;
let open = "none"; // the trigger being listed: "none", "empty" (no effect yet) or "filled"
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card file the course ships: course/cards, each module's cards/, each Expansion Drop solution's cards/. */
function shippedCards() {
const dirs = [
join(courseRoot(), "cards"),
...moduleDirs().flatMap((m) => [join(m.path, "cards"), join(m.path, "expansion", "solution", "cards")]),
].filter((d) => existsSync(d));
return dirs.flatMap((d) =>
readdirSync(d)
.filter((f) => f.endsWith(".json"))
.map((f) => ({ path: join(d, f), def: JSON.parse(readFileSync(join(d, f), "utf8")) })),
);
}
const trigger = { kind: "trigger", on: "deathrattle" };
const effect = { kind: "effect", effect: { type: "draw", count: 1 } };
const aura = { kind: "aura", modifier: { type: "damageRedirect", from: "friendlyHero", to: "self" } };
const keyword = { kind: "keyword", keyword: "taunt" };
test("well_formed_rule", () => {
for (const text of [[], [aura], [keyword, trigger, effect], [trigger, effect, effect, trigger, effect, aura]]) {
assert.equal(wellFormed(text, "minion"), true, JSON.stringify(text));
}
// Loot Hoarder with Baron Rivendare's top half, then his bottom half (Module 5).
for (const text of [[effect], [trigger, aura], [trigger], [trigger, trigger, effect], [trigger, effect, keyword, effect]]) {
assert.equal(wellFormed(text, "minion"), false, JSON.stringify(text));
}
assert.equal(wellFormed([effect], "spell"), true);
});
test("shipped_cards_are_well_formed", () => {
const cards = shippedCards();
assert.ok(cards.some((c) => c.def.name === "Loot Hoarder"), "course/cards was not read");
assert.ok(cards.some((c) => c.def.name === "Graft"), "course/10-spellgrid/cards was not read");
for (const { path, def } of cards) assert.ok(wellFormed(def.text, def.type), `${path}: ill-formed text`);
});