feat: summoned minions enter the grid in Module 10 (a fourth seam; Clay Golem, Clay Shard)
Summoned records its summoner (the minion whose trigger is resolving) inside the engine; the harness log is unchanged. When a Sequence ends, SpellGrid writes spawned text, places each summoned minion with a noun on its summoner's tile, removes the dead, reparses and syncs rule auras. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
820b378929
commit
4eaf24eed2
23 files changed
+361
-42
No files matched your search
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Clay Golem",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 2,
|
||||
"health": 2,
|
||||
"noun": "GOLEM",
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "deathrattle"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "summon",
|
||||
"card": "Clay Shard",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"game": "spellgrid"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Clay Shard",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"noun": "SHARD",
|
||||
"split": 0,
|
||||
"text": [],
|
||||
"game": "spellgrid"
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
{ "name": "SCRIBE", "role": "noun" },
|
||||
{ "name": "KNIGHT", "role": "noun" },
|
||||
{ "name": "CRIER", "role": "noun" },
|
||||
{ "name": "GOLEM", "role": "noun" },
|
||||
{ "name": "SHARD", "role": "noun" },
|
||||
{ "name": "DEATHRATTLE", "role": "noun" },
|
||||
{ "name": "BATTLECRY", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
You now have two engines. Part I's resolves cards: a Sequence of actions, triggers queued and resolved depth-first, deaths held back until the Death Creation Step, auras read from a snapshot, and cards as data. Part II's resolves a grid: words become rules, the rules are recompiled whenever the board changes, and a turn is a fixed list of phases. This module puts both in one small game of our own, **SpellGrid**, where minions with Health and Deathrattles stand on a grid next to word blocks.
|
||||
|
||||
The point is not a bigger engine. It is how little the two engines need to know about each other. SpellGrid joins them at three seams and nowhere else, and every test in this module checks one seam, or a level that needs all three.
|
||||
The point is not a bigger engine. It is how little the two engines need to know about each other. SpellGrid joins them at four seams and nowhere else, and every test in this module checks one seam, or a level that needs several.
|
||||
|
||||
SpellGrid is the course's game, not Blizzard's or Hempuli's. Its cards are SpellGrid originals, marked `"game": "spellgrid"` in `course/catalog.json`, and its rules are the course's choices. Where this lesson says what Hearthstone or Baba Is You does, it cites the source an earlier module already used.
|
||||
|
||||
@@ -84,11 +84,12 @@ One constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, card
|
||||
- **Casting is not a turn.** `won()` only changes in `step`.
|
||||
- **`withNaiveAuraTiming()`** is a test hook for one test: it swaps the two steps at the end of the outermost Phase, so the Aura Update runs before the Death Creation Step.
|
||||
|
||||
The three seams:
|
||||
The four seams:
|
||||
|
||||
1. **Rules become modifiers.** A rule in force `DEATHRATTLE IS DOUBLE` is a trigger multiplier like Baron's (`times: 2`, `stacking: "max"`), for both players' minions. `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them. A rule takes effect at the next Aura Update, like a minion's aura.
|
||||
2. **Hazards mark, the death step removes.** A minion that a turn destroys (a hazard, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy, in one Sequence, and Module 3's Death Creation Step removes it. Its Deathrattle resolves.
|
||||
3. **Grid changes reparse.** When a Sequence ends, the text its effects asked for is written (`spawnText`: "write the word on this minion's tile"), the minions that died leave the grid, and the rules are parsed again, before anything else.
|
||||
4. **Summoned minions enter the grid.** In the same pass, after the text and before the dead leave: a minion that another minion's ability summoned (Clay Golem's Deathrattle: "Summon a Clay Shard.") becomes a grid object on its summoner's tile, if its card has a `"noun"`. It gets the newest id, and the reparse that follows already names it. A minion with no noun (Harvest Golem's Damaged Golem), or one a spell summons, is in play but not on the grid.
|
||||
|
||||
SpellGrid's cards, in `course/10-spellgrid/cards/`. `start-module 10` copies them into your `cards/` with the course's other cards; earlier modules never see them.
|
||||
|
||||
@@ -102,10 +103,12 @@ SpellGrid's cards, in `course/10-spellgrid/cards/`. `start-module 10` copies the
|
||||
| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. |
|
||||
| Tin Knight | minion, 2 | 1/3 | KNIGHT | |
|
||||
| Town Crier | minion, 1 | 1/1 | CRIER | Battlecry: Deal 1 damage to the enemy hero. (The Expansion Drop's.) |
|
||||
| Clay Golem | minion, 2 | 2/2 | GOLEM | Deathrattle: Summon a Clay Shard. |
|
||||
| Clay Shard | minion, 1 | 1/1 | SHARD | |
|
||||
| Spark | spell, 1 | | | Deal 1 damage to a minion. |
|
||||
| Graft | spell, 2 | | | Assemble a Deathrattle minion. |
|
||||
|
||||
Whirlwind is Module 3's Hearthstone card. New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT`, `DEATHRATTLE`, and the property `DOUBLE`.
|
||||
Whirlwind is Module 3's Hearthstone card. New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT`, `GOLEM`, `SHARD`, `DEATHRATTLE`, and the property `DOUBLE`.
|
||||
|
||||
> **How the real game differs**
|
||||
> - SpellGrid is not a real game, and its cards are not Hearthstone cards. Grave Warden is Baron Rivendare's text on a 1-Health body, so that it dies to the same Whirlwind as the minion it would double. Baron is 1/7 ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)).
|
||||
@@ -122,6 +125,8 @@ The full statements are in `course/10-spellgrid/scenarios.md`.
|
||||
- `test_10_hazard_feeds_death_step`: seam 2. The log is exactly `Destroyed`, `DeathPhase`, `Triggered`, `Damaged`.
|
||||
- `test_10_spawned_text_triggers_reparse`: seam 3. `BABA IS WIN` is a rule straight after the cast.
|
||||
- `test_10_targeted_spell_needs_target`: Spark without a target is an error, and the card stays in the hand.
|
||||
- `test_10_summoned_minion_enters_grid`: seam 4. The Clay Golem dies; its Clay Shard stands on the golem's tile, with the newest id, and `SHARD IS PUSH` names it straight after the cast.
|
||||
- `test_10_summon_without_noun_stays_off_grid`: seam 4's other half. A grafted Clay Golem with Harvest Golem's Deathrattle summons a Damaged Golem, which has no noun: it is in play, and the golem's tile is empty.
|
||||
- `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution`: each level, played from a script to `won()`.
|
||||
- `test_10_level_3_naive_would_differ`: level 3 with `withNaiveAuraTiming()`. Same inputs, and the knight dies.
|
||||
|
||||
@@ -165,7 +170,7 @@ knight . . . hedge flag #
|
||||
|
||||
## Build
|
||||
|
||||
Make the ten tests pass, and keep Modules 1–9 green. The shape to aim for:
|
||||
Make the twelve tests pass, and keep Modules 1–9 green. The shape to aim for:
|
||||
|
||||
```text
|
||||
Part I: the card engine Part II: the grid engine
|
||||
@@ -175,27 +180,31 @@ Make the ten tests pass, and keep Modules 1–9 green. The shape to aim for:
|
||||
| activations | | |
|
||||
+-----------------------------+ +------------------------------+
|
||||
^ | ^ | ^ ^
|
||||
(1) | (3) | events | (2) destroy | rules | (2) | (3) write text,
|
||||
auras | v | Sequence v in force| victims | remove the dead,
|
||||
(1) | (3)(4) | events | (2) destroy | rules | (2) | (3) write text,
|
||||
auras | v | Sequence v in force| victims | (4) place summoned,
|
||||
| | | | | | (3) remove the dead
|
||||
+----------------------------------------------------------------------------+
|
||||
| SpellGrid (the only code that knows both) |
|
||||
| (1) rules in force --> rule auras --> Aura Update |
|
||||
| (2) a turn's victims --> minions: one Destroy Sequence; others: removed |
|
||||
| (3) Sequence ends --> TextSpawned: write; Destroyed: remove; reparse |
|
||||
| (3) Sequence ends --> TextSpawned: write |
|
||||
| (4) --> Summoned by a minion on the grid: place, if a noun |
|
||||
| (3) --> Destroyed: remove; reparse |
|
||||
+----------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
- **Neither engine imports the other.** Part I never names a tile; Part II never names a card or a Health. Everything that crosses goes through the code in the middle, and only in the three ways in the diagram. If you catch yourself adding `if this object is a minion` to the grid's hazards, or `where is this minion on the grid` to the card engine, the seam is in the wrong place.
|
||||
- **Neither engine imports the other.** Part I never names a tile; Part II never names a card or a Health. Everything that crosses goes through the code in the middle, and only in the four ways in the diagram. If you catch yourself adding `if this object is a minion` to the grid's hazards, or `where is this minion on the grid` to the card engine, the seam is in the wrong place.
|
||||
- **Seam 1: rules are aura data.** After every reparse, read the rules in force. Each rule whose subject is a trigger word and whose complement is a multiplier word becomes an aura, the same data as Baron's. Hand them to Part I as a list of auras that no minion carries, and let the next Aura Update put them in the snapshot beside the minions' auras. `activations` stays the one routine that counts.
|
||||
- **Negation is a cap.** A negated rule can't be cancelled by resolution, because it *is* the canceller. Make it a modifier of its own kind that limits the count, and let `activations` apply it last.
|
||||
- **Which words are triggers is data.** Put the table (`DEATHRATTLE` → `deathrattle`, `DOUBLE` → twice, `max`) in content.
|
||||
- **Seam 2: a destroy hook.** Module 9's turn removes whatever movement, conversion and hazards destroy. Give the turn a function to call with the victims instead, with "remove them" as the default. SpellGrid's function removes text and things, and sends minions to Part I as one Sequence that marks them all pending destroy. That keeps Module 9's "all objects destroyed in the same step are destroyed simultaneously": they share one Death Creation Step.
|
||||
- **Seam 3: freeze the grid during a Sequence.** Part I has no grid, so `spawnText` can't write one. It records an event, `TextSpawned`, and the code in the middle reads the Sequence's events when it ends: write each word on its minion's tile (the minion is still on the grid, because nothing leaves the grid mid-Sequence), then remove each minion with a `Destroyed` event, then reparse. Reparsing here, inside the turn, is what makes level 2 win in the same step.
|
||||
- **Seam 4: summons read the same events.** Part I records who summoned each minion (`Summoned` carries the summoner inside the engine; the course's log doesn't show it). In the same pass as seam 3, after the text and before the removals, place each summoned minion whose card has a noun on its summoner's tile. The dead are still on the grid at that point, so a Deathrattle's summoner is found where it died. Go through the events in order, so a minion summoned earlier in the same Sequence can itself be a summoner. A summoned minion that dies in the same Sequence is placed and then removed: it still gets an id from the board, as every minion does. Text comes first, so a minion summoned in a Sequence writes nothing in it.
|
||||
- **The test hook is not engine content.** Module 9 made the turn a list of phases. Do the same for the end of the outermost Phase: a list `[Death Creation Step, Aura Update]`, read from the world. The harness hook sets the reversed list. The engine has no "naive" branch; the order is data, and only a test changes it.
|
||||
|
||||
<details><summary>Hint 1 — nudge</summary>
|
||||
|
||||
Write the code in the middle as one file that owns a world and a level, and keeps a map from grid ids to entities. Then write three small functions, one per seam, and call them from `place`, `step`, `cast` and `play`. Start with seam 1: `test_10_spatial_rule_feeds_interceptor` needs nothing else.
|
||||
Write the code in the middle as one file that owns a world and a level, and keeps a map from grid ids to entities. Then write a small function per seam (seams 3 and 4 share one pass), and call them from `place`, `step`, `cast` and `play`. Start with seam 1: `test_10_spatial_rule_feeds_interceptor` needs nothing else.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -205,6 +214,7 @@ Write the code in the middle as one file that owns a world and a level, and keep
|
||||
- **Seam 1.** `ruleAuras(activeRules, table)`: for each rule, look up the subject in the trigger table and the complement in the multiplier table; positive gives `triggerMultiplier`, negated gives `triggerCap` with `times: 1`. Store the list in the world, run an Aura Update. In `activations`, collect the caps per trigger and return `min(cap, max + extra)`.
|
||||
- **Seam 2.** `Level.step(dir, destroy)`. In SpellGrid's `destroy`, split the ids: not a card → remove now; a card → collect. If any cards were collected, bring the rule auras up to date (this turn's movement may have changed the rules), then run one `destroy` Sequence for all of them.
|
||||
- **Seam 3.** Note the log's length, run the Sequence, take the new events. `TextSpawned { source, word }` → place `text:WORD` on the source's tile. `Destroyed { id }` → remove its object. Then reparse, then seam 1 again.
|
||||
- **Seam 4.** When Part I records `Summoned`, it can also record the minion whose trigger is resolving: that is the summoner (none for a spell). Between the text and the removals, `Summoned { id, by }` with a `by` on the grid and a card with a noun → place an object of that noun on `by`'s tile and link it to `id`.
|
||||
- **Playing a card.** `play(card, x, y)` places the object first, then runs Module 4's `play` action: a Battlecry that writes text needs a tile.
|
||||
|
||||
</details>
|
||||
@@ -235,9 +245,12 @@ destroy(level, ids): // seam 2, handed to level.s
|
||||
if id is a minion: minions += entity(id) else: level.board.remove(id)
|
||||
if minions: syncRules(); sequence(Destroy(minions))
|
||||
|
||||
sequence(action): // seam 3
|
||||
sequence(action): // seams 3 and 4
|
||||
from = log.length; runSequence(world, action)
|
||||
for e in log[from..]: if e is TextSpawned: place text:e.word on tile of e.source
|
||||
for e in log[from..]: // seam 4
|
||||
if e is Summoned and e.by is on the grid and card(e.id) has a noun:
|
||||
link(e.id, place noun on tile of e.by)
|
||||
for e in log[from..]: if e is Destroyed: remove object of e.id
|
||||
level.reparse(); syncRules()
|
||||
|
||||
@@ -262,9 +275,9 @@ The level is `l1`, `l2` or `l3` (or a path to a level file of your own). `--dir
|
||||
|
||||
## Name it
|
||||
|
||||
The pattern is an **integration seam**: two subsystems that each keep their own model, joined by a narrow, explicit interface that translates between the models, in one place. Each seam carries one kind of thing, in one direction, at one moment. Rules go from the grid to the cards as aura data, at an Aura Update. Victims go from the grid to the cards as one destroy Sequence, during the turn's phase. Grid changes go from the cards to the grid as events, when a Sequence ends.
|
||||
The pattern is an **integration seam**: two subsystems that each keep their own model, joined by a narrow, explicit interface that translates between the models, in one place. Each seam carries one kind of thing, in one direction, at one moment. Rules go from the grid to the cards as aura data, at an Aura Update. Victims go from the grid to the cards as one destroy Sequence, during the turn's phase. Grid changes go from the cards to the grid as events, when a Sequence ends. Summoned minions go the same way, in the same pass: a fourth seam on the same events, because it carries a different thing (a minion, not a word) and has its own question (which tile).
|
||||
|
||||
Two earlier patterns do most of the work. The **event log** from Module 2 is what lets Part I ask for a grid change without having a grid: it records `TextSpawned` and the seam reads it later. Robert Nystrom's description of an event queue fits: "This decouples the sender from the receiver both statically and in time." ([Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html)). And the **interceptor** from Module 4, one routine that counts activations from data, is what lets a rule on the grid double a Deathrattle without a line of new resolution code. The rule is compiled into the same data Baron is:
|
||||
Two earlier patterns do most of the work. The **event log** from Module 2 is what lets Part I ask for a grid change without having a grid: it records `TextSpawned` and `Summoned`, and the seams read them later. Robert Nystrom's description of an event queue fits: "This decouples the sender from the receiver both statically and in time." ([Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html)). And the **interceptor** from Module 4, one routine that counts activations from data, is what lets a rule on the grid double a Deathrattle without a line of new resolution code. The rule is compiled into the same data Baron is:
|
||||
|
||||
```rust
|
||||
/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a
|
||||
@@ -343,7 +356,7 @@ export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUp
|
||||
> - Hearthstone's end of the outermost Phase has an Aura Update (Health/Attack) before the Death Creation Step ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no Health/Attack auras, so `END_OF_PHASE` leaves it out, as Module 4 did.
|
||||
> - Baba Is You reparses "when any parsable object changes position or is destroyed" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). SpellGrid reparses after each Sequence whether or not it changed the grid. That is cheaper to reason about and gives the same rules.
|
||||
> - A minion that a conversion turns into something else (`KEG IS ROCK`) keeps its card in the reference: the object changes kind, the minion stays in play. No test or level does this, and a real design would have to decide.
|
||||
> - A minion that a card effect summons (Harvest Golem's Damaged Golem) enters play but not the grid. No SpellGrid card summons.
|
||||
> - Where a summoned minion stands is SpellGrid's choice: on the tile of the minion whose ability summoned it, even if that minion has just died there. A spell has no tile, so a minion a spell summons enters play but not the grid; so does a minion whose card has no noun (Harvest Golem's Damaged Golem). Neither game has minions on a grid to copy.
|
||||
> - Module 8 planned every `YOU` object's move "under the rules in force before the first". In SpellGrid, a minion destroyed during movement (`OPEN`/`SHUT`) dies in a Sequence on the spot, and seam 3 reparses when it ends, so the `YOU` objects that move after it use the new rules. The reference accepts this on purpose: a Sequence's grid changes always reparse at once, wherever the Sequence runs, and no level or test depends on the difference. A design that wanted Module 8's rule exactly would hold the reparse until movement ends.
|
||||
|
||||
## Expansion Drop
|
||||
@@ -405,18 +418,18 @@ Both are coherent, and the difference is the old one from Module 7 in new clothe
|
||||
|
||||
</details>
|
||||
|
||||
3. A Harvest Golem stands on the grid and dies. Its Deathrattle summons a Damaged Golem, which "enters play but not the grid". Design the fix. Which engine decides the tile? What if the tile is occupied, or the word `GOLEM` doesn't exist?
|
||||
3. A Clay Golem stands on the grid and dies. Seam 4 puts its Clay Shard on the golem's tile. Which choices did that seam make that neither game made for it? What would change if the tile were occupied, or the token had no noun?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Neither engine can decide alone. Part I knows a minion was summoned, and since Module 3 it even knows *where on the board*, as a neighbour. Part II knows tiles. So it is a fourth seam, and it should look like the third: Part I records `Summoned`, and the code in the middle reads the event when the Sequence ends and places an object.
|
||||
Neither engine could decide alone. Part I knows a minion was summoned, and since Module 3 it even knows *where on the board*, as a neighbour. Part II knows tiles. So the reference built it as a fourth seam that looks like the third: Part I records `Summoned` with the summoner, and the code in the middle reads the event when the Sequence ends and places an object (`engine/spellgrid.*`, `test_10_summoned_minion_enters_grid`).
|
||||
|
||||
Then the questions the seam has to answer, none of which either game answers:
|
||||
The questions the seam had to answer, none of which either game answers:
|
||||
|
||||
- **Which tile?** The dead minion's tile is the natural reading of "where it stood", and Rune Scribe's `spawnText` already uses it. But the dead minion's object is removed in the same pass, so order the two as seam 3 does for text: place first, remove second.
|
||||
- **Occupied?** Grid objects stack, so nothing stops it. Hearthstone's board has a size limit; a grid tile doesn't.
|
||||
- **What noun?** The token needs a `"noun"` in its card data to be anything on the grid. A card with no noun is a minion no rule can name: it can't be `YOU`, `PUSH` or `MELT`. That may even be a feature.
|
||||
- **Order of play versus age.** The token's object is the newest on the grid and its minion the newest in play. Two different counters, as in Module 1, now in two engines.
|
||||
- **Which tile?** The reference uses the summoner's tile: for a Deathrattle, where the dead minion stood, as Rune Scribe's `spawnText` does. The dead minion's object is removed in the same pass, so the seam orders the two as seam 3 does for text: place first, remove second. Another design could pick the nearest empty tile, or ask the player.
|
||||
- **Occupied?** Grid objects stack, so nothing stops it: the shard shares the tile with whatever is there. Hearthstone's board has a size limit; a grid tile doesn't. A design that wanted one minion per tile would have to say what happens to the token that doesn't fit.
|
||||
- **What noun?** The token needs a `"noun"` in its card data to be anything on the grid. A card with no noun is a minion no rule can name, so the reference keeps it off the grid (`test_10_summon_without_noun_stays_off_grid`): it can't be `YOU`, `PUSH` or `MELT`. That may even be a feature. Still open: a minion with a noun that a *spell* summons has no summoner's tile, and stays off the grid too. Is that right for a spell that says where?
|
||||
- **Order of play versus age.** The token's object is the newest on the grid and its minion the newest in play. Two different counters, as in Module 1, now in two engines. They agree here because the seam places tokens in summon order; a design that placed them in any other order would make them disagree.
|
||||
|
||||
The point of the exercise is the list, not the code. A seam is cheap to write once you know what crosses it and when, and those are design questions.
|
||||
|
||||
@@ -465,6 +478,6 @@ The differences are where the design is:
|
||||
- **What it changes.** Baron changes *how many times* something happens: a number, combined with other numbers by `stacking`. `NOT` changes *whether* a rule is in force: a yes or no, and no wins. That is why, in one game, the grid's negation became a cap: a yes/no from Part II meeting a count in Part I.
|
||||
- **Who wins a disagreement.** Two Barons don't stack (`max`); `X IS NOT Y` beats any number of `X IS Y`. None of the Hearthstone cards the course uses can cancel Baron's doubling (the course hasn't surveyed the whole card pool), so SpellGrid had to choose, and chose Baba's answer: negation beats every multiplier, from cards or from the grid.
|
||||
|
||||
What SpellGrid had to decide: that a rule on the grid is the same kind of data as a card's aura (so `stacking` applies, and Warden + `DOUBLE` is two); that it enters the snapshot at the same moments as a card's (so it obeys the Aura Update schedule); and that `NOT` from the grid caps card multipliers too. None of those came from either game. Each is one line of data or one line in `activations`, and each is a test.
|
||||
What SpellGrid had to decide: that a rule on the grid is the same kind of data as a card's aura (so `stacking` applies, and Warden + `DOUBLE` is two); that it enters the snapshot at the same moments as a card's (so it obeys the Aura Update schedule); that `NOT` from the grid caps card multipliers too; and that a minion summoned by a minion on the grid stands on its summoner's tile. None of those came from either game. Each is a little data or a few lines of code, and each is a test.
|
||||
|
||||
</details>
|
||||
@@ -3,13 +3,13 @@
|
||||
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs` (and by `tools/play-spellgrid.mjs`, to play the command line).
|
||||
- `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the four seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, put summoned minions that have a noun on their summoner's tile, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- Part I (`src/engine/`, outside `puzzle/`):
|
||||
- `queue.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event.
|
||||
- `world.rs`: `set_rule_auras`/`rule_auras` (auras that no minion carries), `set_end_of_phase`/`end_of_phase`, `entity_count`.
|
||||
- `world.rs`: `set_rule_auras`/`rule_auras` (auras that no minion carries), `set_end_of_phase`/`end_of_phase`, `entity_count`; `Summoned` records its summoner, `by` (the minion whose trigger is resolving).
|
||||
- `aura.rs`: an Aura Update adds the rule auras for both players; `activations` applies an `Aura::TriggerCap`.
|
||||
- `card.rs`: `Aura::TriggerCap` (not in the card format: only SpellGrid makes it), and `CardDef::noun`.
|
||||
- `effect.rs`: `spawnText` is an action, not a panic. `event.rs`: `TextSpawned`.
|
||||
- `effect.rs`: `spawnText` is an action, not a panic. `event.rs`: `TextSpawned`, and `Summoned.by`.
|
||||
- Part II (`src/engine/puzzle/`):
|
||||
- `level.rs`: `Destroy` and `remove_all`. `step_with` and `move_you_with` take a destroy function, which movement, conversion (`X IS NOT X`) and hazards hand their victims to; `step` and `move_you` pass `remove_all`. `reparse` and `board_mut` are public.
|
||||
- `grid.rs`: `Board::reserve_id`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid), with `LevelCard`.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
{ "name": "SCRIBE", "role": "noun" },
|
||||
{ "name": "KNIGHT", "role": "noun" },
|
||||
{ "name": "CRIER", "role": "noun" },
|
||||
{ "name": "GOLEM", "role": "noun" },
|
||||
{ "name": "SHARD", "role": "noun" },
|
||||
{ "name": "DEATHRATTLE", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
|
||||
@@ -6,8 +6,13 @@ use super::world::{EntityId, PlayerId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
/// `id` entered the board. `by` is the minion whose triggered ability put it there (the
|
||||
/// trigger resolving when it entered), or `None` for a spell, a play or a summon from
|
||||
/// outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log
|
||||
/// doesn't show `by`.
|
||||
Summoned {
|
||||
id: EntityId,
|
||||
by: Option<EntityId>,
|
||||
},
|
||||
Damaged {
|
||||
target: EntityId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Part III: SpellGrid, the two engines in one level.
|
||||
//!
|
||||
//! Part I (the card engine) and Part II (the grid engine, `puzzle`) don't use each
|
||||
//! other. This file is the only place that knows both, and they meet at three seams:
|
||||
//! other. This file is the only place that knows both, and they meet at four seams:
|
||||
//!
|
||||
//! 1. Rules become modifiers. After every reparse, the rules in force that join a
|
||||
//! trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into
|
||||
@@ -15,6 +15,10 @@
|
||||
//! ends, the text its effects asked for (`TextSpawned` events) is written, the
|
||||
//! minions that died (`Destroyed` events) leave the grid, and the rules are parsed
|
||||
//! again, before anything else happens.
|
||||
//! 4. Summoned minions enter the grid. In the same place, before the dead leave: a
|
||||
//! minion that a minion's ability summoned (`Summoned` events with a `by`), whose card
|
||||
//! has a noun, becomes an object of that noun on its summoner's tile. One summoned by
|
||||
//! a spell, or with no noun, is in play but not on the grid.
|
||||
//!
|
||||
//! One id space: a minion's id is its grid object's id. Every other card (a hero, a
|
||||
//! card in hand) gets an id from the same counter that no object will have.
|
||||
@@ -338,13 +342,13 @@ impl CardSide {
|
||||
self.sequence(level, Action::Destroy { targets: minions });
|
||||
}
|
||||
|
||||
/// Run one Sequence in Part I, then seam 3: apply its grid changes and reparse.
|
||||
/// Run one Sequence in Part I, then seams 3 and 4: apply its grid changes and reparse.
|
||||
fn sequence(&mut self, level: &mut Level, action: Action) {
|
||||
let from = self.world.log().len();
|
||||
run_sequence(&mut self.world, action);
|
||||
let events = self.world.log()[from..].to_vec();
|
||||
// Text first, while every minion that wrote it is still on its tile. A minion
|
||||
// with no id (summoned by this Sequence) is not on the grid: nowhere to write.
|
||||
// with no id (summoned by this Sequence) is not on the grid yet: nowhere to write.
|
||||
for event in &events {
|
||||
if let Event::TextSpawned { source, word } = event {
|
||||
let tile = self
|
||||
@@ -358,6 +362,27 @@ impl CardSide {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are
|
||||
// still on the grid here, so a Deathrattle's summoner is found where it died; a
|
||||
// minion summoned earlier in the list is already placed, so it can be a summoner too.
|
||||
for event in &events {
|
||||
let Event::Summoned { id, by: Some(by) } = *event else {
|
||||
continue;
|
||||
};
|
||||
let card = self.world.card_name(id).to_string();
|
||||
if self.ids.contains_key(&id) || self.world.card(&card).def.noun.is_none() {
|
||||
continue;
|
||||
}
|
||||
let tile = self
|
||||
.ids
|
||||
.get(&by)
|
||||
.and_then(|&object| level.board().find(object))
|
||||
.map(|o| (o.x, o.y));
|
||||
if let Some((x, y)) = tile {
|
||||
let object = level.board_mut().place(self.noun_of(&card), x, y);
|
||||
self.link(id, object);
|
||||
}
|
||||
}
|
||||
for event in &events {
|
||||
if let Event::Destroyed { id, .. } = event {
|
||||
if let Some(&object) = self.ids.get(id) {
|
||||
|
||||
@@ -188,7 +188,9 @@ impl World {
|
||||
if zone == Zone::Board {
|
||||
self.next_order_of_play += 1;
|
||||
entity.order_of_play = Some(self.next_order_of_play);
|
||||
self.record(Event::Summoned { id });
|
||||
// The summoner is the minion whose trigger is resolving (the innermost one), if any.
|
||||
let by = self.resolving.last().map(|&(source, _)| source);
|
||||
self.record(Event::Summoned { id, by });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ impl GameEvent {
|
||||
/// doesn't show.
|
||||
fn from_engine(event: &Event) -> Option<GameEvent> {
|
||||
Some(match event.clone() {
|
||||
Event::Summoned { id } => GameEvent::Summoned { id },
|
||||
Event::Summoned { id, .. } => GameEvent::Summoned { id },
|
||||
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
|
||||
Event::Triggered { source, on } => GameEvent::Triggered {
|
||||
source,
|
||||
|
||||
@@ -109,5 +109,5 @@ fn cli_numbers_are_plain_digits() {
|
||||
// "+2" is not a number, so the chooser takes the first option.
|
||||
let level = include_str!("../levels/l2-spliced-corpse.json");
|
||||
let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]);
|
||||
assert!(out.contains("Hand: Ember Moth (Assembled)"), "{out}");
|
||||
assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}");
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs` (and by `tools/play-spellgrid.mjs`, to play the command line).
|
||||
- `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the three seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the four seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, put summoned minions that have a noun on their summoner's tile, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
|
||||
- Part I (`src/engine/`, outside `puzzle/`):
|
||||
- `queue.ts`: `END_OF_PHASE` (`["deathCreation", "auraUpdate"]`) is the list of steps at the end of the outermost Phase, read from the world. The `spawnText` action records a `TextSpawned` event.
|
||||
- `world.ts`: `setRuleAuras`/`ruleAuras` (auras that no minion carries), `setEndOfPhase`/`endOfPhase`, `entityCount`.
|
||||
- `world.ts`: `setRuleAuras`/`ruleAuras` (auras that no minion carries), `setEndOfPhase`/`endOfPhase`, `entityCount`; `Summoned` records its summoner, `by` (the minion whose trigger is resolving).
|
||||
- `aura.ts`: an Aura Update adds the rule auras for both players; `activations` applies a `triggerCap`.
|
||||
- `card.ts`: the `triggerCap` aura (not in the card format: only SpellGrid makes it).
|
||||
- `effect.ts`: `spawnText` is an action, not an error. `event.ts`: `TextSpawned`.
|
||||
- `effect.ts`: `spawnText` is an action, not an error. `event.ts`: `TextSpawned`, and `Summoned.by`.
|
||||
- Part II (`src/engine/puzzle/`):
|
||||
- `level.ts`: `Destroy` and `removeAll`. `step` and `moveYou` take an optional `Destroy`, which movement, conversion (`X IS NOT X`) and hazards hand their victims to. `reparse` is public.
|
||||
- `grid.ts`: `Board.reserveId`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid).
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
{ "name": "SCRIBE", "role": "noun" },
|
||||
{ "name": "KNIGHT", "role": "noun" },
|
||||
{ "name": "CRIER", "role": "noun" },
|
||||
{ "name": "GOLEM", "role": "noun" },
|
||||
{ "name": "SHARD", "role": "noun" },
|
||||
{ "name": "DEATHRATTLE", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { TriggerOn } from "./card";
|
||||
import type { EntityId, PlayerId } from "./world";
|
||||
|
||||
export type Event =
|
||||
| { type: "Summoned"; id: EntityId }
|
||||
/**
|
||||
* `id` entered the board. `by` is the minion whose triggered ability put it there (the
|
||||
* trigger resolving when it entered), or undefined for a spell, a play or a summon from
|
||||
* outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log
|
||||
* doesn't show `by`.
|
||||
*/
|
||||
| { type: "Summoned"; id: EntityId; by: EntityId | undefined }
|
||||
| { type: "Damaged"; target: EntityId; amount: number }
|
||||
| { type: "Triggered"; source: EntityId; on: TriggerOn }
|
||||
| { type: "Drew"; player: PlayerId; id: EntityId }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Part III: SpellGrid, the two engines in one level.
|
||||
//
|
||||
// Part I (the card engine) and Part II (the grid engine, `puzzle/`) don't import each
|
||||
// other. This file is the only place that knows both, and they meet at three seams:
|
||||
// other. This file is the only place that knows both, and they meet at four seams:
|
||||
//
|
||||
// 1. Rules become modifiers. After every reparse, the rules in force that join a
|
||||
// trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into
|
||||
@@ -15,6 +15,10 @@
|
||||
// ends, the text its effects asked for (`TextSpawned` events) is written, the
|
||||
// minions that died (`Destroyed` events) leave the grid, and the rules are parsed
|
||||
// again, before anything else happens.
|
||||
// 4. Summoned minions enter the grid. In the same place, before the dead leave: a
|
||||
// minion that a minion's ability summoned (`Summoned` events with a `by`), whose card
|
||||
// has a noun, becomes an object of that noun on its summoner's tile. One summoned by
|
||||
// a spell, or with no noun, is in play but not on the grid.
|
||||
//
|
||||
// One id space: a minion's id is its grid object's id. Every other card (a hero, a
|
||||
// card in hand) gets an id from the same counter that no object will have.
|
||||
@@ -223,7 +227,7 @@ export class SpellGrid {
|
||||
this.sequence({ type: "destroy", targets: minions.map((id) => this.entity(id)) });
|
||||
};
|
||||
|
||||
/** Run one Sequence in Part I, then seam 3: apply its grid changes and reparse. */
|
||||
/** Run one Sequence in Part I, then seams 3 and 4: apply its grid changes and reparse. */
|
||||
private sequence(action: Action): void {
|
||||
const from = this.world.log().length;
|
||||
runSequence(this.world, action);
|
||||
@@ -232,11 +236,22 @@ export class SpellGrid {
|
||||
// Text first, while every minion that wrote it is still on its tile.
|
||||
for (const event of events) {
|
||||
if (event.type !== "TextSpawned") continue;
|
||||
// A minion with no id (summoned by this Sequence) is not on the grid: nowhere to write.
|
||||
// A minion with no id (summoned by this Sequence) is not on the grid yet: nowhere to write.
|
||||
const id = this.ids.get(event.source);
|
||||
const source = id === undefined ? undefined : board.find(id);
|
||||
if (source !== undefined) board.place({ type: "text", word: event.word }, source.x, source.y);
|
||||
}
|
||||
// Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are still
|
||||
// on the grid here, so a Deathrattle's summoner is found where it died; a minion summoned
|
||||
// earlier in the list is already placed, so it can be a summoner too.
|
||||
for (const event of events) {
|
||||
if (event.type !== "Summoned" || event.by === undefined) continue;
|
||||
const card = this.world.cardName(event.id);
|
||||
if (this.ids.has(event.id) || this.world.card(card).def.noun === undefined) continue;
|
||||
const by = this.ids.get(event.by);
|
||||
const summoner = by === undefined ? undefined : board.find(by);
|
||||
if (summoner !== undefined) this.link(event.id, board.place(this.nounOf(card), summoner.x, summoner.y));
|
||||
}
|
||||
for (const event of deaths(events)) {
|
||||
const id = this.ids.get(event.id);
|
||||
if (id !== undefined) board.remove(id);
|
||||
|
||||
@@ -126,7 +126,8 @@ export class World {
|
||||
entity.zone = zone;
|
||||
if (zone === "board") {
|
||||
entity.orderOfPlay = ++this.nextOrderOfPlay;
|
||||
this.record({ type: "Summoned", id });
|
||||
// The summoner is the minion whose trigger is resolving (the innermost one), if any.
|
||||
this.record({ type: "Summoned", id, by: this.resolving.at(-1)?.[0] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export type GameEvent =
|
||||
function toGameEvent(event: Event): GameEvent | undefined {
|
||||
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
|
||||
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
|
||||
if (event.type === "Summoned") return { type: "Summoned", id: event.id };
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
if (event.type === "Played") return undefined;
|
||||
// SpellGrid writes the text on the grid; the course's log doesn't show the request.
|
||||
|
||||
@@ -66,5 +66,5 @@ test("cli_numbers_are_plain_digits", () => {
|
||||
expect(cast.out).toContain('"Whirlwind -1 0" is not in the hand');
|
||||
// "+2" is not a number, so the chooser takes the first option.
|
||||
const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]);
|
||||
expect(graft.out).toContain("Hand: Ember Moth (Assembled)");
|
||||
expect(graft.out).toContain("Hand: Clay Golem (Assembled)");
|
||||
});
|
||||
@@ -14,11 +14,12 @@ SpellGrid is the course's own small game: Part I's card engine and Part II's gri
|
||||
- `health(id)`, `hero(player)`, `nameOf(id)`, `log()` and `clearLog()` are Part I's, with ids translated as above. `minionAt(x, y)` is the id of the minion on that tile, or `null` (Rust: `Option<Id>`). `hand()` lists the card names in player 0's hand, in order.
|
||||
- `withNaiveAuraTiming()` is a test hook (Rust: `with_naive_aura_timing()`). It reverses the two steps at the end of an outermost Phase, so that the Aura Update runs *before* the Death Creation Step and not after it. Only `test_10_level_3_naive_would_differ` calls it.
|
||||
|
||||
The three seams between the engines, each tested below:
|
||||
The four seams between the engines, each tested below:
|
||||
|
||||
1. **Rules become modifiers.** A rule in force `<TRIGGER> IS DOUBLE` (`DEATHRATTLE IS DOUBLE`) is a trigger multiplier like Baron Rivendare's (`times: 2`, `stacking: "max"`), for every player's minions. `DEATHRATTLE IS NOT DOUBLE` caps that trigger at one activation, whatever else multiplies it. The rule takes effect at the next Aura Update, like a minion's aura.
|
||||
2. **Hazards mark, the death step removes.** A minion that a hazard (or `OPEN`/`SHUT`, or `X IS NOT X`) destroys is marked pending destroy, in one Sequence. Module 3's Death Creation Step removes it, and its Deathrattle resolves.
|
||||
3. **Grid changes reparse.** A minion that leaves play leaves the grid, and an effect that writes on the grid (`spawnText`: "put the word W on this minion's tile") writes there. Both happen when the Sequence ends, and then the rules are parsed again, before anything else.
|
||||
4. **Summoned minions enter the grid.** In the same pass, after the text is written and before the dead leave: a minion that a minion's ability summoned, whose card has a `"noun"`, becomes an object of that noun on its summoner's tile (where the summoner stands, or stood if it died in this Sequence), with the newest id. A minion summoned by a spell, or whose card has no noun, is in play but not on the grid. The log is unchanged: `Summoned { id }` reports the new object's id.
|
||||
|
||||
Casting is not a turn: there is no win check until the next `step`.
|
||||
|
||||
@@ -33,12 +34,14 @@ SpellGrid's own cards (`"game": "spellgrid"` in `course/catalog.json`; not Heart
|
||||
| Ember Moth | minion, 1 | 1/1 | MOTH | Taunt. Deathrattle: Deal 2 damage to the enemy hero. (`split` 1) |
|
||||
| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. (`split` 0) |
|
||||
| Tin Knight | minion, 2 | 1/3 | KNIGHT | (none) |
|
||||
| Clay Golem | minion, 2 | 2/2 | GOLEM | Deathrattle: Summon a Clay Shard. (`split` 1) |
|
||||
| Clay Shard | minion, 1 | 1/1 | SHARD | (none) |
|
||||
| Spark | spell, 1 | | | Deal 1 damage to a minion. |
|
||||
| Graft | spell, 2 | | | Assemble a Deathrattle minion. |
|
||||
|
||||
Graft is Module 5's `assemble` effect: the chooser picks a base from the Deathrattle minions among the course's cards, a donor, and a half, and the Assembled minion goes to the hand. It keeps the base's name, stats and noun. Whirlwind ("Deal 1 damage to ALL minions.") is the Hearthstone card from Module 3.
|
||||
|
||||
New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT` and `DEATHRATTLE`, and the property `DOUBLE`. A sentence like `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` is three text objects, as in Modules 6–9.
|
||||
New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT`, `GOLEM`, `SHARD` and `DEATHRATTLE`, and the property `DOUBLE`. A sentence like `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` is three text objects, as in Modules 6–9.
|
||||
|
||||
### test_10_spatial_rule_feeds_interceptor
|
||||
|
||||
@@ -100,6 +103,28 @@ New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNI
|
||||
- Action 2: `cast("Spark", bramble)`.
|
||||
- Assertions 2: `health(bramble)` is 2 and `hand()` is empty.
|
||||
|
||||
### test_10_summoned_minion_enters_grid
|
||||
|
||||
- Setup: a level 5 wide and 3 high with `SHARD (0,0) IS (1,0) PUSH (2,0)`; card Clay Golem at (2,2); hand `["Spark", "Spark"]`.
|
||||
- Action: `cast("Spark", golem)`, then `clearLog()`, then `cast("Spark", golem)`.
|
||||
- Assertions, with `shard` the minion at (2,2):
|
||||
- The log is exactly `[SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: shard }]`: the log reports the shard by its grid id.
|
||||
- `kindsAt(2,2)` is `["shard"]`: the shard stands on the golem's tile, and the golem left the grid. `posOf(golem)` is `null`.
|
||||
- `nameOf(shard)` is `"Clay Shard"` and `health(shard)` is 1.
|
||||
- `activeRules()` contains `"SHARD IS PUSH"` and `hasProperty(shard, "PUSH")` is true, straight after the cast: the shard was placed before the reparse.
|
||||
- `shard` is greater than `golem`, `hero(0)` and `hero(1)`, and the next `place("rock", 4, 2)` returns `shard + 1`: the shard has the newest id.
|
||||
|
||||
### test_10_summon_without_noun_stays_off_grid
|
||||
|
||||
- Setup: a level 3 wide and 1 high with no objects; hand `["Graft", "Spark", "Spark"]`; `setChooser` answering `"Clay Golem"` for the base, `"Harvest Golem"` for the donor and `"bottom"` for the half.
|
||||
- Action 1: `cast("Graft")`.
|
||||
- Assertions 1: `hand()` is `["Spark", "Spark", "Clay Golem (Assembled)"]`: the golem's noun with Harvest Golem's Deathrattle, "Summon a 2/1 Damaged Golem."
|
||||
- Action 2: `play("Clay Golem (Assembled)", 1, 0)`, returning `golem`; `cast("Spark", golem)`; `clearLog()`; `cast("Spark", golem)`.
|
||||
- Assertions 2, with `token` the id in the log's last event:
|
||||
- The log is exactly `[SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: token }]`.
|
||||
- `nameOf(token)` is `"Damaged Golem"` and `health(token)` is 1: it is in play.
|
||||
- `posOf(token)` is `null`, `kindsAt(1,0)` is empty and `minionAt(1,0)` is `null`: Damaged Golem has no noun, so it is not on the grid.
|
||||
|
||||
### test_10_level_1_solution
|
||||
|
||||
Level `levels/l1-double-tap.json`. The Bramble Wall (3 Health) is `STOP` and blocks the only way to the flag. Whirlwind and one Powder Keg Deathrattle do 2 damage, one short.
|
||||
|
||||
@@ -263,6 +263,93 @@ fn test_10_targeted_spell_needs_target() {
|
||||
assert_eq!(g.hand(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// The events of a Spark that kills `minion`, whose Deathrattle summons `summoned`.
|
||||
fn spark_kills_summoner(minion: Id, summoned: Id) -> Vec<GameEvent> {
|
||||
vec![
|
||||
GameEvent::SpellCast {
|
||||
card: "Spark".to_string(),
|
||||
},
|
||||
GameEvent::Damaged {
|
||||
target: minion,
|
||||
amount: 1,
|
||||
},
|
||||
GameEvent::Destroyed { id: minion },
|
||||
GameEvent::DeathPhase { round: 1 },
|
||||
GameEvent::Triggered {
|
||||
source: minion,
|
||||
trigger: "deathrattle".to_string(),
|
||||
},
|
||||
GameEvent::Summoned { id: summoned },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_summoned_minion_enters_grid() {
|
||||
let mut g = new_spell_grid(
|
||||
level(
|
||||
5,
|
||||
3,
|
||||
&[("SHARD", 0, 0), ("IS", 1, 0), ("PUSH", 2, 0)],
|
||||
&[],
|
||||
&[("Clay Golem", 2, 2)],
|
||||
&["Spark", "Spark"],
|
||||
),
|
||||
vec![],
|
||||
);
|
||||
let golem = minion(&g, 2, 2);
|
||||
g.cast("Spark", Some(golem));
|
||||
|
||||
g.clear_log();
|
||||
g.cast("Spark", Some(golem));
|
||||
|
||||
let shard = minion(&g, 2, 2);
|
||||
assert_eq!(g.log(), spark_kills_summoner(golem, shard));
|
||||
assert_eq!(g.kinds_at(2, 2), vec!["shard"]);
|
||||
assert_eq!(g.pos_of(golem), None);
|
||||
assert_eq!(g.name_of(shard), "Clay Shard");
|
||||
assert_eq!(g.health(shard), 1);
|
||||
assert!(has(&g.active_rules(), "SHARD IS PUSH"));
|
||||
assert!(g.has_property(shard, "PUSH"));
|
||||
for older in [golem, g.hero(0), g.hero(1)] {
|
||||
assert!(shard > older);
|
||||
}
|
||||
assert_eq!(g.place("rock", 4, 2), shard + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_summon_without_noun_stays_off_grid() {
|
||||
let mut g = new_spell_grid(
|
||||
level(3, 1, &[], &[], &[], &["Graft", "Spark", "Spark"]),
|
||||
vec![],
|
||||
);
|
||||
g.set_chooser(|kind, _| {
|
||||
match kind {
|
||||
"base" => "Clay Golem",
|
||||
"donor" => "Harvest Golem",
|
||||
_ => "bottom",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
g.cast("Graft", None);
|
||||
|
||||
assert_eq!(g.hand(), vec!["Spark", "Spark", "Clay Golem (Assembled)"]);
|
||||
|
||||
let golem = g.play("Clay Golem (Assembled)", 1, 0);
|
||||
g.cast("Spark", Some(golem));
|
||||
g.clear_log();
|
||||
g.cast("Spark", Some(golem));
|
||||
|
||||
let Some(&GameEvent::Summoned { id: token }) = g.log().last() else {
|
||||
panic!("the last event is not a Summoned: {:?}", g.log());
|
||||
};
|
||||
assert_eq!(g.log(), spark_kills_summoner(golem, token));
|
||||
assert_eq!(g.name_of(token), "Damaged Golem");
|
||||
assert_eq!(g.health(token), 1);
|
||||
assert_eq!(g.pos_of(token), None);
|
||||
assert_eq!(g.kinds_at(1, 0), Vec::<String>::new());
|
||||
assert_eq!(g.minion_at(1, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_1_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
|
||||
@@ -221,6 +221,70 @@ test("test_10_targeted_spell_needs_target", () => {
|
||||
expect(g.hand()).toEqual([]);
|
||||
});
|
||||
|
||||
/** The events of a Spark that kills `minion`, whose Deathrattle summons `summoned`. */
|
||||
const sparkKillsSummoner = (minion: Id, summoned: Id) => [
|
||||
{ type: "SpellCast", card: "Spark" },
|
||||
{ type: "Damaged", target: minion, amount: 1 },
|
||||
{ type: "Destroyed", id: minion },
|
||||
{ type: "DeathPhase", round: 1 },
|
||||
{ type: "Triggered", source: minion, trigger: "deathrattle" },
|
||||
{ type: "Summoned", id: summoned },
|
||||
];
|
||||
|
||||
test("test_10_summoned_minion_enters_grid", () => {
|
||||
const g = newSpellGrid(
|
||||
level(5, 3, {
|
||||
words: [
|
||||
["SHARD", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["PUSH", 2, 0],
|
||||
],
|
||||
cards: [["Clay Golem", 2, 2]],
|
||||
hand: ["Spark", "Spark"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const golem = minion(g, 2, 2);
|
||||
g.cast("Spark", golem);
|
||||
|
||||
g.clearLog();
|
||||
g.cast("Spark", golem);
|
||||
|
||||
const shard = minion(g, 2, 2);
|
||||
expect(g.log()).toEqual(sparkKillsSummoner(golem, shard));
|
||||
expect(g.kindsAt(2, 2)).toEqual(["shard"]);
|
||||
expect(g.posOf(golem)).toBeNull();
|
||||
expect(g.nameOf(shard)).toBe("Clay Shard");
|
||||
expect(g.health(shard)).toBe(1);
|
||||
expect(g.activeRules()).toContain("SHARD IS PUSH");
|
||||
expect(g.hasProperty(shard, "PUSH")).toBe(true);
|
||||
for (const older of [golem, g.hero(0), g.hero(1)]) expect(shard).toBeGreaterThan(older);
|
||||
expect(g.place("rock", 4, 2)).toBe(shard + 1);
|
||||
});
|
||||
|
||||
test("test_10_summon_without_noun_stays_off_grid", () => {
|
||||
const g = newSpellGrid(level(3, 1, { hand: ["Graft", "Spark", "Spark"] }), []);
|
||||
g.setChooser((kind) => (kind === "base" ? "Clay Golem" : kind === "donor" ? "Harvest Golem" : "bottom"));
|
||||
g.cast("Graft");
|
||||
|
||||
expect(g.hand()).toEqual(["Spark", "Spark", "Clay Golem (Assembled)"]);
|
||||
|
||||
const golem = g.play("Clay Golem (Assembled)", 1, 0);
|
||||
g.cast("Spark", golem);
|
||||
g.clearLog();
|
||||
g.cast("Spark", golem);
|
||||
|
||||
const last = g.log().at(-1);
|
||||
if (last?.type !== "Summoned") throw new Error(`the last event is not a Summoned: ${JSON.stringify(g.log())}`);
|
||||
const token = last.id;
|
||||
expect(g.log()).toEqual(sparkKillsSummoner(golem, token));
|
||||
expect(g.nameOf(token)).toBe("Damaged Golem");
|
||||
expect(g.health(token)).toBe(1);
|
||||
expect(g.posOf(token)).toBeNull();
|
||||
expect(g.kindsAt(1, 0)).toEqual([]);
|
||||
expect(g.minionAt(1, 0)).toBeNull();
|
||||
});
|
||||
|
||||
test("test_10_level_1_solution", () => {
|
||||
const g = newSpellGrid(l1 as LevelDef, []);
|
||||
const keg = minion(g, 0, 4);
|
||||
|
||||
@@ -576,6 +576,40 @@
|
||||
"text": "Battlecry: Deal 1 damage to the enemy hero.",
|
||||
"game": "spellgrid"
|
||||
},
|
||||
{
|
||||
"name": "Clay Golem",
|
||||
"kind": "card",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 2,
|
||||
"health": 2,
|
||||
"text": "Deathrattle: Summon a Clay Shard.",
|
||||
"game": "spellgrid"
|
||||
},
|
||||
{
|
||||
"name": "Clay Shard",
|
||||
"kind": "card",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"text": "",
|
||||
"game": "spellgrid"
|
||||
},
|
||||
{
|
||||
"name": "GOLEM",
|
||||
"kind": "word",
|
||||
"role": "noun",
|
||||
"text": "Clay Golem's noun (a SpellGrid minion on the grid)",
|
||||
"game": "spellgrid"
|
||||
},
|
||||
{
|
||||
"name": "SHARD",
|
||||
"kind": "word",
|
||||
"role": "noun",
|
||||
"text": "Clay Shard's noun (a SpellGrid minion on the grid; a Clay Golem's Deathrattle puts one there)",
|
||||
"game": "spellgrid"
|
||||
},
|
||||
{
|
||||
"name": "BATTLECRY",
|
||||
"kind": "word",
|
||||
|
||||
+1
-1
@@ -166,4 +166,4 @@ From M10, `LevelDef` has two more optional fields, `cards: { card, x, y }[]` and
|
||||
|
||||
SpellGrid has one id space: a minion's id is its grid object's id, so `posOf`, `hasProperty` and `health` take the same id, and `log()` reports it. A hero or a card in hand gets an id that no grid object has; a card played from hand gets its grid object's id (returned by `play`). `cast(card, target?)` casts a spell from player 0's hand as one Sequence (target: a minion's id); `play(card, x, y)` plays a minion from player 0's hand onto an empty tile as one Sequence (it enters the board, then its Battlecry). Both are errors for a card not in the hand, and `play` for a tile that holds anything. Casting is not a turn: `won()` changes only in `step`.
|
||||
|
||||
The three seams: (1) a rule in force `<TRIGGER> IS <MULTIPLIER>` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and `<TRIGGER> IS NOT <MULTIPLIER>` caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (`spawnText`) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. Trigger words (`DEATHRATTLE`, and `BATTLECRY` after the Expansion Drop) are nouns in the lexicon (`words.json`), so they can be the subject of a rule, but `role: "meta"` in `course/catalog.json`: they name the engine's own triggers, and the coupling audit checks only role-noun words. `checkCast(card, target?)` and `checkPlay(card, x, y)` (Rust: `check_cast`, `check_play`, returning `Result<(), String>`) say why `cast`/`play` would fail, or `null`/`Ok`; the command line uses them, the tests don't. `withNaiveAuraTiming()` reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only `test_10_level_3_naive_would_differ` uses it.
|
||||
The four seams: (1) a rule in force `<TRIGGER> IS <MULTIPLIER>` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and `<TRIGGER> IS NOT <MULTIPLIER>` caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (`spawnText`) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. (4) In that same pass, after the text and before the dead leave the grid, each minion that a minion's triggered ability summoned, whose card has a `"noun"`, becomes an object of that noun on its summoner's tile (the summoner may have died in this Sequence), with a new id from the board: `log()` reports that id. A minion summoned by a spell, or whose card has no noun, is in play but not on the grid. Trigger words (`DEATHRATTLE`, and `BATTLECRY` after the Expansion Drop) are nouns in the lexicon (`words.json`), so they can be the subject of a rule, but `role: "meta"` in `course/catalog.json`: they name the engine's own triggers, and the coupling audit checks only role-noun words. `checkCast(card, target?)` and `checkPlay(card, x, y)` (Rust: `check_cast`, `check_play`, returning `Result<(), String>`) say why `cast`/`play` would fail, or `null`/`Ok`; the command line uses them, the tests don't. `withNaiveAuraTiming()` reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only `test_10_level_3_naive_would_differ` uses it.
|
||||
Reference in new issue
Block a user