feat: module 05 assemble
Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
55fe5a71d7
commit
e04dc1a11f
@@ -0,0 +1,23 @@
|
||||
# Module 5 Expansion Drop: Fleshshape
|
||||
|
||||
The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check.
|
||||
|
||||
- Card: Fleshshape, 3-mana Death Knight spell, "Assemble a Deathrattle minion. Give all minions in your hand +2/+2." ([Fleshshape, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Fleshshape)).
|
||||
- Tests: `tests/rust/test_05x.rs` and `tests/ts/test_05x.test.ts`. Copy the one for your track into your workspace's `tests/`.
|
||||
- Reference answer: `solution/cards/fleshshape.json`, one card file for both tracks. Copy it into your workspace's `cards/`. Nothing under `src/engine/` changes, and neither does the harness. It is not in `course/cards/`, so `start-module` doesn't hand it to you.
|
||||
- Check: commit your workspace before you start, then run `node tools/expansion-check.mjs <your-workspace> <that-commit>` from the course repository. It fails if any file under `src/engine/` changed.
|
||||
|
||||
Fleshshape is `Test Workshop` (a Module 5 test card) with two values changed: the pool filter's trigger is `deathrattle` instead of `battlecry`, and the buff is +2/+2 instead of +1/+1. The wiki says a pool named on the card applies only to the base minion ([Assemble, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Assemble)), which is what the `filter` of an `assemble` effect does: the donor options are not filtered.
|
||||
|
||||
## Scenario
|
||||
|
||||
### test_05x_fleshshape_pool_filter
|
||||
|
||||
- Setup: `newGame()` (no decks). Set a chooser that records every call and answers `"Loot Hoarder"` for `"base"`, `"Novice Engineer"` for `"donor"` and `"top"` for `"half"`. Call `clearLog()`.
|
||||
- Action: player 0 casts `Fleshshape`.
|
||||
- Assertions:
|
||||
- The chooser was called exactly three times, with kinds `"base"`, `"donor"`, `"half"` in that order.
|
||||
- The `"base"` options are exactly the names of the minion files in `cards/` that have a `deathrattle` trigger clause, sorted. That list includes `Loot Hoarder` and not `Novice Engineer`. (With the course's cards: `Harvest Golem`, `Loot Hoarder`, `Unstable Ghoul`.)
|
||||
- The `"donor"` options include `"Novice Engineer"`: the filter applies to the base only.
|
||||
- `hand(0)` has exactly 1 card, A. `nameOf(A)` is `"Loot Hoarder (Assembled)"` ("Battlecry: Draw a card."), `cost(A)` is 2, `attack(A)` is 4 and `health(A)` is 3: Loot Hoarder's 2/1, then +2/+2.
|
||||
- The log is exactly: `SpellCast{card: "Fleshshape"}`, `StatsChanged{id: A, attack: 4, health: 3}`.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Fleshshape",
|
||||
"type": "spell",
|
||||
"cost": 3,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "assemble",
|
||||
"filter": {
|
||||
"trigger": "deathrattle"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "buffHand",
|
||||
"attack": 2,
|
||||
"health": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Module 5 Expansion Drop: Fleshshape. Matches expansion/README.md.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use engine::harness::*;
|
||||
|
||||
/// Names of the minion files in `cards/` with a trigger clause listening for `on`, sorted.
|
||||
fn minions_with_trigger(on: &str) -> Vec<String> {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/cards");
|
||||
let mut names = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {dir}: {e}")) {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|x| x.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let card: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let has_trigger = card["text"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "trigger" && c["on"] == on);
|
||||
if card["type"] == "minion" && has_trigger {
|
||||
names.push(card["name"].as_str().unwrap().to_string());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05x_fleshshape_pool_filter() {
|
||||
let mut g = new_game(GameOpts::default());
|
||||
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" => "Loot Hoarder",
|
||||
"donor" => "Novice Engineer",
|
||||
_ => "top",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
g.clear_log();
|
||||
|
||||
g.cast("Fleshshape", 0, None);
|
||||
|
||||
let calls = calls.borrow().clone();
|
||||
let kinds: Vec<&str> = calls.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(kinds, vec!["base", "donor", "half"]);
|
||||
let bases = minions_with_trigger("deathrattle");
|
||||
assert!(bases.contains(&"Loot Hoarder".to_string()));
|
||||
assert!(!bases.contains(&"Novice Engineer".to_string()));
|
||||
assert_eq!(calls[0].1, bases);
|
||||
assert!(calls[1].1.contains(&"Novice Engineer".to_string()));
|
||||
|
||||
let hand = g.hand(0);
|
||||
assert_eq!(hand.len(), 1);
|
||||
let a = hand[0];
|
||||
assert_eq!(g.name_of(a), "Loot Hoarder (Assembled)");
|
||||
assert_eq!(g.cost(a), 2);
|
||||
assert_eq!(g.attack(a), 4);
|
||||
assert_eq!(g.health(a), 3);
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::SpellCast {
|
||||
card: "Fleshshape".to_string()
|
||||
},
|
||||
GameEvent::StatsChanged {
|
||||
id: a,
|
||||
attack: 4,
|
||||
health: 3
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// <reference types="vite/client" />
|
||||
// Module 5 Expansion Drop: Fleshshape. Matches expansion/README.md.
|
||||
import { expect, test } from "vitest";
|
||||
import { newGame, type CardDef } from "../src/harness";
|
||||
|
||||
const files = import.meta.glob<CardDef>("../cards/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** Names of the minion files in `cards/` with a trigger clause listening for `on`, sorted. */
|
||||
const minionsWithTrigger = (on: string): string[] =>
|
||||
Object.values(files)
|
||||
.filter((c) => c.type === "minion" && c.text.some((clause) => clause.kind === "trigger" && clause.on === on))
|
||||
.map((c) => c.name)
|
||||
.sort();
|
||||
|
||||
test("test_05x_fleshshape_pool_filter", () => {
|
||||
const g = newGame();
|
||||
const calls: [string, string[]][] = [];
|
||||
g.setChooser((kind, options) => {
|
||||
calls.push([kind, [...options]]);
|
||||
return kind === "base" ? "Loot Hoarder" : kind === "donor" ? "Novice Engineer" : "top";
|
||||
});
|
||||
g.clearLog();
|
||||
|
||||
g.cast("Fleshshape", 0);
|
||||
|
||||
expect(calls.map(([kind]) => kind)).toEqual(["base", "donor", "half"]);
|
||||
const bases = minionsWithTrigger("deathrattle");
|
||||
expect(bases).toContain("Loot Hoarder");
|
||||
expect(bases).not.toContain("Novice Engineer");
|
||||
expect(calls[0][1]).toEqual(bases);
|
||||
expect(calls[1][1]).toContain("Novice Engineer");
|
||||
|
||||
const hand = g.hand(0);
|
||||
expect(hand).toHaveLength(1);
|
||||
const [a] = hand;
|
||||
expect(g.nameOf(a)).toBe("Loot Hoarder (Assembled)");
|
||||
expect(g.cost(a)).toBe(2);
|
||||
expect(g.attack(a)).toBe(4);
|
||||
expect(g.health(a)).toBe(3);
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "SpellCast", card: "Fleshshape" },
|
||||
{ type: "StatsChanged", id: a, attack: 4, health: 3 },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
# Module 5: Behavior as data and Assemble
|
||||
|
||||
In Modules 2–4 a card's behaviour was a function in your content: Harvest Golem's Deathrattle was a few lines of Rust or TypeScript that returned a Summon action. That was enough to keep card names out of the engine. It is not enough for this module's mechanic, which takes half of one card's text and glues it onto another card. You can't cut a function in half. So in this module every card becomes data, a JSON file that your engine reads and interprets, and the functions in your content go away. Then Assemble is a few lines of list surgery, and the last Hearthstone card of the course is a data file.
|
||||
|
||||
You need your Module 4 workspace with all Module 1–4 tests passing, under git. The Module 4 Expansion Drop (Brann Bronzebeard) is not needed. This module is mostly a refactor, and your Module 1–4 tests are its safety net: they must stay green while every card moves out of code.
|
||||
|
||||
## Observe
|
||||
|
||||
Assemble arrived in Patch 37.0 (October 2026), with the Reign of the Black Empire expansion ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)). Blizzard's announcement says only "Combine minions from a curated pool to build custom creations." ([Reign of the Black Empire Is Now Live!](https://news.blizzard.com/en-us/article/24304163/)). The keyword's reminder text:
|
||||
|
||||
> Get a random minion. Change half its text.
|
||||
|
||||
The wiki page gives the details (all from [Assemble](https://hearthstone.wiki.gg/wiki/Assemble), paraphrased where not quoted):
|
||||
|
||||
- It "adds a random minion to the hand, then presents two other minions."
|
||||
- The added minion "can have either the top or bottom half of its text replaced with the corresponding text from either presented minion".
|
||||
- The Assembled minion keeps its own stats, cost, name, art, rarity and minion type. Only its text changes.
|
||||
- "The Assembled minion must have to have its text altered." (The typo is the wiki's.)
|
||||
- The base comes from a curated pool of about 250 minions whose text can be split in half. The two presented minions cost the same as the base or up to 2 more.
|
||||
- When a card names a pool, only the base minion must come from it.
|
||||
|
||||
One of the 13 cards with Assemble is a spell, Fleshshape, 3-mana ([Fleshshape](https://hearthstone.wiki.gg/wiki/Fleshshape)):
|
||||
|
||||
> Assemble a Deathrattle minion. Give all minions in your hand +2/+2.
|
||||
|
||||
Two minions you already have, and one new one ([Harvest Golem](https://hearthstone.wiki.gg/wiki/Harvest_Golem), [Dread Infernal](https://hearthstone.wiki.gg/wiki/Dread_Infernal), [Loot Hoarder](https://hearthstone.wiki.gg/wiki/Loot_Hoarder)):
|
||||
|
||||
- Harvest Golem, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem."
|
||||
- Dread Infernal, 6-mana 6/6: "Battlecry: Deal 1 damage to ALL other characters."
|
||||
- Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card."
|
||||
|
||||
Notice what the wiki does **not** say: what a "half" is. It gives no worked example, and it doesn't say whether the split is between trigger and effect, between two sentences, or something else ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)). For "Deathrattle: Summon a 2/1 Damaged Golem." the obvious reading is "Deathrattle:" on top and "Summon a 2/1 Damaged Golem." below, but nothing in the sources confirms it. The course has to define "half" itself, and it does so in data: see Spec.
|
||||
|
||||
## Predict
|
||||
|
||||
Write your answers down before you open the discussion, for example in `notes/05.md` in your workspace.
|
||||
|
||||
1. Take Harvest Golem and replace its bottom half with Dread Infernal's. What does the new card say? Its stats? You summon Baron Rivendare, a 1/1, a 1/3 and then the new card, and destroy the new card. Count the damage events. When does the 1/1 leave play?
|
||||
2. In your Module 4 engine, Harvest Golem's Deathrattle is a function. Write down what `assemble("Harvest Golem", "Dread Infernal", "bottom")` would have to do with that function. Can it?
|
||||
3. Replace Loot Hoarder's *top* half with Novice Engineer's. What does the card do when you play it? When it dies?
|
||||
4. Assemble Loot Hoarder with Loot Hoarder. Now replace Loot Hoarder's bottom half with Novice Engineer's bottom half. What should happen in each case?
|
||||
5. Fleshshape picks a minion at random. How do you write a test whose expected log is exact?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
1. "Deathrattle: Deal 1 damage to ALL other characters." It is still Harvest Golem's 3-cost 2/3 body. With Baron, the Deathrattle activates twice: each activation damages both heroes and the three minions still on the board, so ten `Damaged` events and both heroes at 28. The 1/1 is at 0 Health after the first activation but stays in play (Module 3), so the second activation hits it again. It leaves play in the Death Creation Step after the Death Phase, and the 1/3 survives at 1.
|
||||
|
||||
2. It can't. A function is opaque: there is no "top half" of a closure to keep and no "bottom half" of another closure to splice in. You could special-case pairs of cards, or write the effects as several small functions that `assemble` recombines, but at that point you are already turning text into data, just without saying so.
|
||||
|
||||
3. "Battlecry: Draw a card." on Loot Hoarder's 2/1 body: one draw when played, nothing when it dies. The Deathrattle is gone.
|
||||
|
||||
4. Both are errors: the new text equals the old. The first is obvious. The second is less so: Loot Hoarder's and Novice Engineer's bottom halves are both "Draw a card.", so the "changed" card is identical. The rule ("must have its text altered") is about the result, not about which card the half came from, which is why `assemble` compares texts.
|
||||
|
||||
5. You don't let the engine roll dice in the test. Every random choice goes through one function the game owns, a *chooser*; the real game asks the player or picks at random, and a test injects a chooser that answers what the test wants and records what it was offered. The test then checks the offer, which is the part of the rule that is deterministic.
|
||||
|
||||
</details>
|
||||
|
||||
## Spec
|
||||
|
||||
From the course repository, copy the module's tests into your workspace:
|
||||
|
||||
```sh
|
||||
node tools/start-module.mjs 05 ~/my-engine
|
||||
```
|
||||
|
||||
`start-module` now also copies the course's card files into `~/my-engine/cards/`: one JSON file per card, in the format of `course/schema/card.schema.json`. `course/cards/` is the single source; re-running `start-module` refreshes those files and keeps any you added.
|
||||
|
||||
### The card format
|
||||
|
||||
A card is a name, a type (`minion` or `spell`), a cost, Attack and Health for minions, a `text` and a `split`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Harvest Golem",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 2,
|
||||
"health": 3,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{ "kind": "trigger", "on": "deathrattle" },
|
||||
{ "kind": "effect", "effect": { "type": "summon", "card": "Damaged Golem", "count": 1 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `text` is a list of **clauses**. A clause is a `keyword` (for example `taunt`), a `trigger` (`on`: `deathrattle`, `battlecry`, `selfDamaged`, `friendlyMinionDamaged`, `anyMinionDamaged`, `heroDamaged`, `endOfTurn`), an `aura` (the Module 4 trigger multiplier, now in its final form) or an `effect`.
|
||||
- A trigger clause applies to the effect clauses after it, until the next trigger, keyword or aura. Effect clauses before any trigger are what a spell does when cast.
|
||||
- Effects: `dealDamage{target, amount}`, `draw{count}`, `summon{card, count}`, `gainArmor{amount}`, `gainAttack{target, amount}`, `destroy{target}`, and two new ones, `assemble{filter}` and `buffHand{attack, health}`. Targets: `self`, `allMinions`, `allOtherMinions`, `allOtherCharacters`, `enemyHero`, `chosen`. (`spawnText` belongs to Part III; you can ignore it until then.)
|
||||
- **`split` is the course's definition of "half"**: the top half is `text[0:split]`, the bottom half `text[split:]`. Each card's data says where its top half ends. For the course's trigger cards that is right after the trigger, so "Deathrattle:" is the top and "Summon a 2/1 Damaged Golem." the bottom. Baron Rivendare's single aura clause has `split` 0: all bottom.
|
||||
|
||||
### The harness
|
||||
|
||||
| TypeScript | Rust |
|
||||
|---|---|
|
||||
| `registerCards(defs: CardDef[])` | `register_cards(&mut self, defs: Vec<CardDef>)` |
|
||||
| `assemble(base, donor, half: "top" \| "bottom"): string` (throws `AssembleError`) | `assemble(&mut self, base: &str, donor: &str, half: Half) -> Result<String, AssembleError>` |
|
||||
| `cost(id): number` | `cost(&self, id: Id) -> i32` |
|
||||
| `setChooser(choose: (kind, options) => string)` | `set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)` |
|
||||
|
||||
- `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.
|
||||
|
||||
### The tests
|
||||
|
||||
The full statements are in `course/05-assemble/scenarios.md`.
|
||||
|
||||
- `test_05_regression_data_cards`: every file in `cards/` parses, `registerCards` accepts them all, and every minion summons with the file's name, Attack, Health and cost. The rest of the regression is your Module 1–4 tests: they now run against cards loaded from data.
|
||||
- `test_05_assemble_bottom_half`: Harvest Golem with Dread Infernal's bottom half, under Baron, next to a `Test Filler` (1/1) and a `Test Sponge` (1/3). Destroy it. Exact log: two activations of "Deal 1 damage to ALL other characters", the Filler dies afterwards, both heroes at 28.
|
||||
- `test_05_assembled_keeps_base_stats`: the same card in hand costs 3 and is 2/3.
|
||||
- `test_05_assemble_must_change_text`: Loot Hoarder with Loot Hoarder fails, top and bottom.
|
||||
- `test_05_top_half_swap`: Loot Hoarder with Novice Engineer's top half. Playing it draws once; destroying it draws nothing.
|
||||
- `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.
|
||||
|
||||
The top-half test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_05_top_half_swap() {
|
||||
let mut g = new_game(GameOpts {
|
||||
decks: Some([fillers(5), vec![]]),
|
||||
});
|
||||
let name = g
|
||||
.assemble("Loot Hoarder", "Novice Engineer", Half::Top)
|
||||
.unwrap();
|
||||
let e = g.add_to_hand(&name, 0);
|
||||
g.clear_log();
|
||||
|
||||
g.play(e);
|
||||
|
||||
let hand = g.hand(0);
|
||||
assert_eq!(hand.len(), 1);
|
||||
let c = hand[0];
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Summoned { id: e },
|
||||
triggered(e, "battlecry"),
|
||||
GameEvent::Drew { player: 0, id: c },
|
||||
]
|
||||
);
|
||||
assert_eq!(g.attack(e), 2);
|
||||
assert_eq!(g.health(e), 1);
|
||||
|
||||
g.clear_log();
|
||||
g.destroy(e);
|
||||
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Destroyed { id: e },
|
||||
GameEvent::DeathPhase { round: 1 },
|
||||
]
|
||||
);
|
||||
assert_eq!(g.hand(0).len(), 1);
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
test("test_05_top_half_swap", () => {
|
||||
const g = newGame({ decks: [fillers(5), []] });
|
||||
const name = g.assemble("Loot Hoarder", "Novice Engineer", "top");
|
||||
const e = g.addToHand(name, 0);
|
||||
g.clearLog();
|
||||
|
||||
g.play(e);
|
||||
|
||||
const hand = g.hand(0);
|
||||
expect(hand).toHaveLength(1);
|
||||
const [c] = hand;
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Summoned", id: e },
|
||||
{ type: "Triggered", source: e, trigger: "battlecry" },
|
||||
{ type: "Drew", player: 0, id: c },
|
||||
]);
|
||||
expect(g.attack(e)).toBe(2);
|
||||
expect(g.health(e)).toBe(1);
|
||||
|
||||
g.clearLog();
|
||||
g.destroy(e);
|
||||
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Destroyed", id: e },
|
||||
{ type: "DeathPhase", round: 1 },
|
||||
]);
|
||||
expect(g.hand(0)).toHaveLength(1);
|
||||
});
|
||||
```
|
||||
|
||||
Run `cargo test` or `npm test`. The Module 5 tests fail to compile (Rust) or fail (TypeScript): the harness has no `assemble`, and Rust has no `CardDef` the tests can parse. Modules 1–4 still pass. That is RED.
|
||||
|
||||
## Build
|
||||
|
||||
Make the seven 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.
|
||||
|
||||
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.
|
||||
|
||||
<details><summary>Hint 1 — nudge</summary>
|
||||
|
||||
Look at what your Module 4 card functions actually did. Each one built a short list of actions from a handful of facts: who owns the card, which minion is running the trigger, where it stood, which minions are in play. Every function is "an action kind plus some parameters". That pair is what the JSON stores; the facts are what your interpreter supplies.
|
||||
|
||||
Then Assemble: if a card's text is a list, what is "half of a list"?
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 2 — concept</summary>
|
||||
|
||||
- **Parse, then compile once.** When a definition is registered, walk its clauses once and build what the engine reads while resolving: a list of triggers (each with its `on` and its effects), a list of auras (Module 4's `activations` reads these unchanged), and a spell's effects. A trigger collects the effect clauses after it until the next trigger, keyword or aura.
|
||||
- **One interpreter.** `actions(effect, ctx) -> Action[]`. Targets resolve against the context: `self` is the source minion; `allOtherCharacters` is both heroes plus the minions in play, minus the source.
|
||||
- **Interpret one effect at a time.** For each effect of a resolving ability, interpret it, perform its actions (depth-first, as before), then read the next effect. Fleshshape's "Give all minions in your hand +2/+2." must see the minion its first sentence put there.
|
||||
- **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.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 3 — pseudo-code</summary>
|
||||
|
||||
```text
|
||||
register(def):
|
||||
triggers = []; auras = []; onCast = []; open = none
|
||||
for clause in def.text:
|
||||
trigger: open = {on, effects: []}; triggers.push(open)
|
||||
effect: (open ? open.effects : onCast).push(clause.effect)
|
||||
aura: auras.push(clause.modifier); open = none
|
||||
keyword: open = none
|
||||
library[def.name] = {def, triggers, auras, onCast,
|
||||
inPool: def is a minion and def.text not empty}
|
||||
|
||||
runEffects(effects, ctx): // used by react() and by Cast
|
||||
for e in effects:
|
||||
for a in actions(e, ctx): perform(a)
|
||||
|
||||
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)
|
||||
assemble(f) -> [Assemble(ctx.owner, f)]
|
||||
buffHand(a, h) -> [Buff(id, a, h) for id in minions in ctx.owner's hand]
|
||||
...
|
||||
|
||||
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
|
||||
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])
|
||||
register assemble(base, donor, half); create it in player's hand
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
When your tests pass, compare with the reference engine in `course/05-assemble/reference/rust/src/engine/` or `course/05-assemble/reference/ts/src/engine/`: `card` (types, compile, library), `effect` (the interpreter) and `assemble`. The reference's content directory is now a short loader that reads files.
|
||||
|
||||
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.
|
||||
- **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 only fixes the first name.
|
||||
- **The default chooser** takes the first option. A real game would ask the player or roll; the tests never rely on the default.
|
||||
- **Unknown keywords do nothing.** Unstable Ghoul's data says `taunt`; the course has no combat, so the engine keeps the clause (it is part of the text Assemble moves) and ignores it.
|
||||
|
||||
> **How the real game differs**
|
||||
> - The real Assemble puts a *random* minion in your hand and presents *two* other minions ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)). The course offers every eligible minion and lets the chooser pick each part. A random chooser that draws one base and two donors gives back the real game's shape; the engine doesn't change.
|
||||
> - The presented minions cost the same as the base or up to 2 more; for 9- and 10-cost bases they can cost 8, 9 or 10. Minions come from your class or neutral unless the card says otherwise (same source). That is part of *selection*, so it belongs in the chooser's options, and the course doesn't model it: it has no mana and no classes.
|
||||
> - The real pool is about 250 curated minions "whose effects can be split in half" (paraphrase, same source). The course's pool is every minion with text, and every card's data says where its half ends.
|
||||
> - The wiki doesn't define "half" (same source). The course's `split` is a definition, not a claim about the real game.
|
||||
> - The real Assembled minion also keeps its art, rarity and minion type (same source). The course's cards have none of these.
|
||||
|
||||
## Name it
|
||||
|
||||
The pattern is **behaviour as data**, interpreted. A card's ability is no longer code the engine calls; it is a small tree (clauses, holding effects, holding targets) that one routine in the engine walks. Robert Nystrom's [*Bytecode*](https://gameprogrammingpatterns.com/bytecode.html) chapter states the goal: "Give behavior the flexibility of data by encoding it as instructions for a virtual machine." Its motivating example is a spell system, and the reason it gives is the course's reason too: "We could define these spells in code, but that means an engineer has to be involved every time one is modified." Read it for that argument, and for the sandboxing point below. Don't read it as advice to build a virtual machine: the chapter contrasts the *Interpreter* pattern (walk a tree of expression objects) with a compact bytecode, and the course stays deliberately on the tree side. Card abilities are short, and a tree you can print, compare and cut in half is worth more here than speed.
|
||||
|
||||
The interpreter in the reference, one effect in, actions out:
|
||||
|
||||
```rust
|
||||
pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
|
||||
let owner = ctx.owner;
|
||||
match effect {
|
||||
Effect::DealDamage { target, amount } => vec![Action::Damage {
|
||||
targets: targets(*target, ctx),
|
||||
amount: *amount,
|
||||
}],
|
||||
Effect::Draw { count } => (0..*count)
|
||||
.map(|_| Action::Draw { player: owner })
|
||||
.collect(),
|
||||
// ... summon, gainArmor, gainAttack, destroy ...
|
||||
Effect::Assemble { filter } => vec![Action::Assemble {
|
||||
player: owner,
|
||||
filter: filter.clone(),
|
||||
}],
|
||||
// ... buffHand, spawnText ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
export function actions(effect: Effect, ctx: Ctx): Action[] {
|
||||
const player = ctx.owner;
|
||||
switch (effect.type) {
|
||||
case "dealDamage":
|
||||
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
|
||||
case "draw":
|
||||
return times(effect.count, () => ({ type: "draw", player }));
|
||||
// ... summon, gainArmor, gainAttack, destroy ...
|
||||
case "assemble":
|
||||
return [{ type: "assemble", player, filter: effect.filter }];
|
||||
// ... buffHand, spawnText ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And Assemble, which only works because of it:
|
||||
|
||||
```rust
|
||||
pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
|
||||
let (base_top, base_bottom) = base.text.split_at(base.split);
|
||||
let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
|
||||
let (top, bottom) = match half {
|
||||
Half::Top => (donor_top, base_bottom),
|
||||
Half::Bottom => (base_top, donor_bottom),
|
||||
};
|
||||
let text = [top, bottom].concat();
|
||||
if text == base.text {
|
||||
return Err(AssembleError {
|
||||
base: base.name.clone(),
|
||||
donor: donor.name.clone(),
|
||||
half,
|
||||
});
|
||||
}
|
||||
Ok(CardDef {
|
||||
name: format!("{} (Assembled)", base.name),
|
||||
split: top.len(),
|
||||
text,
|
||||
..base.clone()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
|
||||
const topOf = (c: CardDef) => c.text.slice(0, c.split);
|
||||
const bottomOf = (c: CardDef) => c.text.slice(c.split);
|
||||
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);
|
||||
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
[Forge](https://github.com/Card-Forge/forge/wiki/Card-scripting-API), a long-lived Java engine for the same game, scripts its cards as plain-text key/value data. A trigger line such as `T:Mode$ DamageDone | ValidSource$ Card.Self | ValidTarget$ Opponent` names its effect indirectly (`Execute$ TrigDraw`), and the effect is another line: `SVar:TrigDraw:DB$ Draw | Defined$ You | NumCards$ 1`. Compare that with Acolyte of Pain's file in `cards/`: a trigger clause, then an effect clause. Different syntax, same split between *when* and *what*, and the same extensible effect vocabulary.
|
||||
|
||||
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows clause kinds, effect kinds, targets, halves and choosers. Card names live only in `cards/`.
|
||||
|
||||
## Expansion Drop
|
||||
|
||||
A new card arrives: Fleshshape, "Assemble a Deathrattle minion. Give all minions in your hand +2/+2." ([Fleshshape](https://hearthstone.wiki.gg/wiki/Fleshshape)). It is not in `cards/`: `course/cards/` leaves it out on purpose.
|
||||
|
||||
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't need to change either. This time not even `src/content/` should change: the drop is one new file in `cards/`.
|
||||
|
||||
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 5 done"`.
|
||||
2. Copy the expansion test for your track from `course/05-assemble/expansion/tests/rust/test_05x.rs` or `course/05-assemble/expansion/tests/ts/test_05x.test.ts` into your workspace's `tests/`. Run it and watch it fail: there is no card called Fleshshape.
|
||||
3. Write `cards/fleshshape.json`.
|
||||
4. When the tests pass, run the check from the course repository:
|
||||
|
||||
```sh
|
||||
node tools/expansion-check.mjs ~/my-engine HEAD
|
||||
```
|
||||
|
||||
The test, `test_05x_fleshshape_pool_filter`, casts Fleshshape with an injected chooser. It checks that the base options are exactly the Deathrattle minions in `cards/`, that the donor options are not filtered (the wiki says a named pool applies only to the base, [Assemble](https://hearthstone.wiki.gg/wiki/Assemble)), and that the Assembled Loot Hoarder ends up 4/3. The full scenario is in `course/05-assemble/expansion/README.md`.
|
||||
|
||||
<details><summary>Discussion: what this drop measures</summary>
|
||||
|
||||
If your `assemble` effect takes its filter from data and your effects run one at a time, Fleshshape is `Test Workshop` with two values changed:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Fleshshape",
|
||||
"type": "spell",
|
||||
"cost": 3,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{ "kind": "effect", "effect": { "type": "assemble", "filter": { "trigger": "deathrattle" } } },
|
||||
{ "kind": "effect", "effect": { "type": "buffHand", "attack": 2, "health": 2 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
That is the reference answer in `course/05-assemble/expansion/solution/cards/fleshshape.json` (pretty-printed there), the same file for both tracks.
|
||||
|
||||
Shapes that fail this drop:
|
||||
|
||||
- **The filter is code.** If `Test Workshop` passed because your engine checks for Battlecries inside the `assemble` effect, Deathrattle needs another branch.
|
||||
- **Effects computed up front.** If an ability's actions are all built before any is performed, "Give all minions in your hand +2/+2" is computed while the hand is still empty of the new minion: the test sees 2/1, not 4/3.
|
||||
- **Cards still in code.** If Test Workshop is a function in your content, Fleshshape is another function, and "add a JSON file" isn't available to you.
|
||||
|
||||
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- Robert Nystrom, [*Bytecode*](https://gameprogrammingpatterns.com/bytecode.html), *Game Programming Patterns*: read it for the case for behaviour as data and for the sandboxing argument. The course stops at the Interpreter pattern the chapter starts from, on purpose.
|
||||
- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §1.1 ("Cards are pure, serializable data structures") and §1.2 (amounts as inspectable AST nodes). Compare §1.2 with the course's integer `amount`: what would "deal damage equal to your hand size" need?
|
||||
- Forge wiki, [Card scripting API](https://github.com/Card-Forge/forge/wiki/Card-scripting-API) and [Creating a custom Card](https://github.com/Card-Forge/forge/wiki/Creating-a-custom-Card): abilities as data at the scale of a whole game's card pool, with triggers, statics and replacement effects as separate kinds of line.
|
||||
- [Assemble, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Assemble): the rules, the 13 cards with the keyword, and what the page leaves undefined. Check whether it defines "half" by the time you read it.
|
||||
@@ -0,0 +1 @@
|
||||
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "05" }
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Module 5: cards are JSON data.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Module 5 reference engine (Rust)
|
||||
|
||||
The course's own engine as of the end of Module 5. Every card is now data: a JSON file in the course's card format (`course/schema/card.schema.json`), and the Rust functions that were card effects in Modules 2–4 are gone. One interpreter turns effects into actions. Assemble builds a new card by replacing half of one card's text with the same half of another's. Module 4's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` (at the crate root; not in this directory): the card data. It is a copy of `course/cards/`, the single source: `tools/verify-references.mjs` stages it next to the crate, and `tools/start-module.mjs` copies it into a learner's workspace.
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `card.rs`: the card format as Rust types (`CardDef`, `Clause`, `Effect`, `Target`, `Aura`, read with `serde`), and the library. Registering a definition compiles its clauses into triggers, auras and a spell's effects, and marks whether Assemble may offer it (minions with text that were not themselves assembled). `TriggerOn` gains `EndOfTurn`, which card data may name (Drakkari Enchanter's aura does); the course has no turns, so nothing fires it.
|
||||
- `effect.rs`: the interpreter: one effect plus its context in, actions out. The only code that knows what `dealDamage`, `draw`, `summon`, `assemble`, `buffHand`, ... mean.
|
||||
- `assemble.rs`: `assemble(base, donor, half)` (list surgery on `text` at `split`; fails with `AssembleError` if the text is unchanged), and the `assemble` effect, which asks the chooser for a base from the filtered pool, a donor and a half.
|
||||
- `queue.rs`: as in Module 4, plus two actions (`Assemble`, `Buff`). An ability's effects are interpreted and performed one at a time, in printed order.
|
||||
- `world.rs`: as in Module 4, plus card registration, cost, the chooser, and `buff`.
|
||||
- `aura.rs`, `death.rs`, `event.rs`: unchanged from Module 4.
|
||||
- `src/content/mod.rs`: reads every `.json` file in `cards/`. No card is named in Rust any more.
|
||||
- `src/harness.rs`: the adapter the course tests call. Module 5 adds `register_cards`, `assemble`, `cost` and `set_chooser`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 05` from the course repository. `cargo test` in this directory alone fails: there is no `cards/` here.
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Content: the cards. Only this directory (and the card data in `cards/`) may name
|
||||
//! specific cards.
|
||||
//!
|
||||
//! From Module 5 every card is a JSON file in `cards/` at the crate root, in the
|
||||
//! course's card format (`course/schema/card.schema.json`). The functions that
|
||||
//! were card effects in Modules 2–4 are gone: the engine interprets the data.
|
||||
//! `start-module` copies the course's card files into `cards/`; add your own there.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::engine::CardDef;
|
||||
|
||||
/// Every card in `cards/`, in file-name order. Read once per test run.
|
||||
pub fn cards() -> Vec<CardDef> {
|
||||
static CARDS: OnceLock<Vec<CardDef>> = OnceLock::new();
|
||||
CARDS
|
||||
.get_or_init(|| load_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("cards")))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Parse every `.json` file in `dir` as a card definition.
|
||||
pub fn load_dir(dir: &Path) -> Vec<CardDef> {
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.unwrap_or_else(|e| panic!("cannot read card directory {}: {e}", dir.display()));
|
||||
let mut paths: Vec<_> = entries
|
||||
.map(|entry| entry.expect("card directory entry").path())
|
||||
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||
serde_json::from_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{} is not a valid card: {e}", path.display()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Assemble: "Get a random minion. Change half its text."
|
||||
//!
|
||||
//! Card text is a list of clauses, so changing half of it is list surgery: the base
|
||||
//! card's `text[..split]` (top) or `text[split..]` (bottom) is replaced by the same
|
||||
//! half of a donor card. Name, cost and stats stay the base's. This works only
|
||||
//! because a card's behaviour is its data: the new card needs no new code.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use super::card::{CardDef, PoolFilter};
|
||||
use super::world::{EntityId, PlayerId, World, Zone};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Half {
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Half {
|
||||
pub const BOTH: [Half; 2] = [Half::Top, Half::Bottom];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Half::Top => "top",
|
||||
Half::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssembleError {
|
||||
pub base: String,
|
||||
pub donor: String,
|
||||
pub half: Half,
|
||||
}
|
||||
|
||||
impl fmt::Display for AssembleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
|
||||
self.base,
|
||||
self.half.label(),
|
||||
self.donor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AssembleError {}
|
||||
|
||||
/// The base card with `half` of its text replaced by the same half of `donor`.
|
||||
/// It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
||||
pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
|
||||
let (base_top, base_bottom) = base.text.split_at(base.split);
|
||||
let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
|
||||
let (top, bottom) = match half {
|
||||
Half::Top => (donor_top, base_bottom),
|
||||
Half::Bottom => (base_top, donor_bottom),
|
||||
};
|
||||
let text = [top, bottom].concat();
|
||||
if text == base.text {
|
||||
return Err(AssembleError {
|
||||
base: base.name.clone(),
|
||||
donor: donor.name.clone(),
|
||||
half,
|
||||
});
|
||||
}
|
||||
Ok(CardDef {
|
||||
name: format!("{} (Assembled)", base.name),
|
||||
split: top.len(),
|
||||
text,
|
||||
..base.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Register an assembled card and return its name. If a different card already has
|
||||
/// the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
||||
pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
|
||||
let stem = def.name.clone();
|
||||
let mut n = 1;
|
||||
while world.has_card(&def.name) && world.card(&def.name).def != def {
|
||||
n += 1;
|
||||
def.name = format!("{} {n})", stem.trim_end_matches(')'));
|
||||
}
|
||||
let name = def.name.clone();
|
||||
world.register_assembled(def);
|
||||
name
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn assemble_from_pool(
|
||||
world: &mut World,
|
||||
player: PlayerId,
|
||||
filter: &PoolFilter,
|
||||
) -> Option<EntityId> {
|
||||
// Each pool card, and whether the filter lets it be the base.
|
||||
let pool: Vec<(CardDef, bool)> = world
|
||||
.pool()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.def.clone(),
|
||||
filter.trigger.map_or(true, |on| c.has_trigger(on)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let bases: Vec<String> = pool
|
||||
.iter()
|
||||
.filter(|(_, allowed)| *allowed)
|
||||
.map(|(d, _)| d.name.clone())
|
||||
.collect();
|
||||
if bases.is_empty() {
|
||||
return None;
|
||||
}
|
||||
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 = world.card(&donor_name).def.clone();
|
||||
|
||||
let halves: Vec<String> = Half::BOTH
|
||||
.iter()
|
||||
.filter(|&&h| changes(&donor, h))
|
||||
.map(|h| h.label().to_string())
|
||||
.collect();
|
||||
let half = world.choose("half", &halves);
|
||||
let half = if half == "top" {
|
||||
Half::Top
|
||||
} else {
|
||||
Half::Bottom
|
||||
};
|
||||
|
||||
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
|
||||
let name = register_assembled(world, def);
|
||||
Some(world.create(&name, player, Zone::Hand))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Auras, and the one routine that applies trigger multipliers.
|
||||
//!
|
||||
//! An aura is data on a card: a continuous effect its minion has while in play.
|
||||
//! The engine never asks "is that card on the board?" in the middle of resolving
|
||||
//! something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
//! Update) and every rule reads the snapshot.
|
||||
//!
|
||||
//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
//! Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
//! Summoning a minion also runs an Aura Update.
|
||||
|
||||
use super::card::{Aura, Stacking, TriggerOn};
|
||||
use super::world::{ActiveAura, PlayerId, World};
|
||||
|
||||
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
|
||||
pub fn aura_update_other(world: &mut World) {
|
||||
let mut active = Vec::new();
|
||||
for id in world.minions_in_play() {
|
||||
for &aura in &world.card(world.card_name(id)).auras {
|
||||
active.push(ActiveAura {
|
||||
owner: world.owner_of(id),
|
||||
aura,
|
||||
});
|
||||
}
|
||||
}
|
||||
world.set_active_auras(active);
|
||||
}
|
||||
|
||||
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
|
||||
/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations.
|
||||
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
|
||||
let mut max = 1;
|
||||
let mut extra = 0;
|
||||
for active in world.active_auras() {
|
||||
let Aura::TriggerMultiplier {
|
||||
trigger,
|
||||
times,
|
||||
stacking,
|
||||
} = active.aura;
|
||||
if active.owner != owner || trigger != on {
|
||||
continue;
|
||||
}
|
||||
match stacking {
|
||||
Stacking::Max => max = max.max(times),
|
||||
Stacking::Additive => extra += times - 1,
|
||||
}
|
||||
}
|
||||
max + extra
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
||||
//! read straight from JSON. The engine compiles each definition once, when it is
|
||||
//! registered, into the lists it reads while resolving: triggers, auras and a
|
||||
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// What a triggered ability listens for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TriggerOn {
|
||||
/// This minion takes damage.
|
||||
SelfDamaged,
|
||||
/// A minion with the same owner (this one included) takes damage.
|
||||
FriendlyMinionDamaged,
|
||||
/// Any minion takes damage.
|
||||
AnyMinionDamaged,
|
||||
/// Either hero takes damage.
|
||||
HeroDamaged,
|
||||
/// This minion dies. It resolves after the minion has left play.
|
||||
Deathrattle,
|
||||
/// This minion is played from hand.
|
||||
Battlecry,
|
||||
/// The end of its owner's turn. The course has no turns, so it never fires; card
|
||||
/// data may still name it, for example in a trigger multiplier.
|
||||
EndOfTurn,
|
||||
}
|
||||
|
||||
impl TriggerOn {
|
||||
/// The trigger's family name, as reported in `Triggered` events.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TriggerOn::SelfDamaged
|
||||
| TriggerOn::FriendlyMinionDamaged
|
||||
| TriggerOn::AnyMinionDamaged
|
||||
| TriggerOn::HeroDamaged => "damaged",
|
||||
TriggerOn::Deathrattle => "deathrattle",
|
||||
TriggerOn::Battlecry => "battlecry",
|
||||
TriggerOn::EndOfTurn => "endOfTurn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How several trigger multipliers for the same trigger combine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Stacking {
|
||||
/// The largest multiplier wins: two copies of a doubler still double.
|
||||
Max,
|
||||
/// Each copy adds its extra activations.
|
||||
Additive,
|
||||
}
|
||||
|
||||
/// An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Aura {
|
||||
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
|
||||
/// owner, activate `times` times each.
|
||||
TriggerMultiplier {
|
||||
trigger: TriggerOn,
|
||||
times: u32,
|
||||
stacking: Stacking,
|
||||
},
|
||||
}
|
||||
|
||||
/// Who an effect touches. `This` is the schema's `"self"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Target {
|
||||
#[serde(rename = "self")]
|
||||
This,
|
||||
AllMinions,
|
||||
AllOtherMinions,
|
||||
AllOtherCharacters,
|
||||
EnemyHero,
|
||||
Chosen,
|
||||
}
|
||||
|
||||
/// Which minions an Assemble may pick as its base. An empty filter allows any.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct PoolFilter {
|
||||
/// Only minions with a trigger clause listening for this.
|
||||
#[serde(default)]
|
||||
pub trigger: Option<TriggerOn>,
|
||||
}
|
||||
|
||||
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Effect {
|
||||
DealDamage {
|
||||
target: Target,
|
||||
amount: i32,
|
||||
},
|
||||
Draw {
|
||||
count: u32,
|
||||
},
|
||||
Summon {
|
||||
card: String,
|
||||
count: u32,
|
||||
},
|
||||
GainArmor {
|
||||
amount: i32,
|
||||
},
|
||||
GainAttack {
|
||||
target: Target,
|
||||
amount: i32,
|
||||
},
|
||||
Destroy {
|
||||
target: Target,
|
||||
},
|
||||
/// Part III (SpellGrid) only: puts a word on the grid.
|
||||
SpawnText {
|
||||
word: String,
|
||||
at: String,
|
||||
},
|
||||
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
|
||||
Assemble {
|
||||
filter: PoolFilter,
|
||||
},
|
||||
/// Give every minion in the owner's hand +attack/+health.
|
||||
BuffHand {
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// One line of card text. A trigger clause applies to the effect clauses after it,
|
||||
/// until the next trigger, keyword or aura. Effects before any trigger are what a
|
||||
/// spell does when cast.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Clause {
|
||||
Keyword { keyword: String },
|
||||
Trigger { on: TriggerOn },
|
||||
Aura { modifier: Aura },
|
||||
Effect { effect: Effect },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CardType {
|
||||
Minion,
|
||||
Spell,
|
||||
}
|
||||
|
||||
/// A card as data, in the course's card format. Fields the engine doesn't use
|
||||
/// (`fictional`, `game`, `noun`) are ignored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct CardDef {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub card_type: CardType,
|
||||
pub cost: i32,
|
||||
#[serde(default)]
|
||||
pub attack: i32,
|
||||
#[serde(default)]
|
||||
pub health: i32,
|
||||
pub text: Vec<Clause>,
|
||||
/// `text[..split]` is the top half, `text[split..]` the bottom half.
|
||||
pub split: usize,
|
||||
}
|
||||
|
||||
/// A triggered ability: "whenever <on>, <effects>".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Trigger {
|
||||
pub on: TriggerOn,
|
||||
pub effects: Vec<Effect>,
|
||||
}
|
||||
|
||||
/// A registered card: its definition, and the definition's text compiled into the
|
||||
/// lists the engine reads while resolving.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Card {
|
||||
pub def: CardDef,
|
||||
/// Triggered abilities, in printed order.
|
||||
pub triggers: Vec<Trigger>,
|
||||
/// Auras this minion has while in play.
|
||||
pub auras: Vec<Aura>,
|
||||
/// What the card does when cast (a spell's effects).
|
||||
pub on_cast: Vec<Effect>,
|
||||
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
|
||||
pub in_pool: bool,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Compile a definition's text. Panics if `split` is past the end of the text.
|
||||
pub fn compile(def: CardDef, in_pool: bool) -> Card {
|
||||
assert!(
|
||||
def.split <= def.text.len(),
|
||||
"{:?}: split {} is past the end of its text",
|
||||
def.name,
|
||||
def.split
|
||||
);
|
||||
let mut triggers: Vec<Trigger> = Vec::new();
|
||||
let mut auras = Vec::new();
|
||||
let mut on_cast = Vec::new();
|
||||
// Is the latest clause a trigger whose effects are still being listed?
|
||||
let mut open_trigger = false;
|
||||
for clause in &def.text {
|
||||
match clause {
|
||||
Clause::Trigger { on } => {
|
||||
triggers.push(Trigger {
|
||||
on: *on,
|
||||
effects: Vec::new(),
|
||||
});
|
||||
open_trigger = true;
|
||||
}
|
||||
Clause::Effect { effect } if open_trigger => {
|
||||
triggers.last_mut().unwrap().effects.push(effect.clone());
|
||||
}
|
||||
Clause::Effect { effect } => on_cast.push(effect.clone()),
|
||||
Clause::Aura { modifier } => {
|
||||
auras.push(*modifier);
|
||||
open_trigger = false;
|
||||
}
|
||||
// The course has no combat, so keywords such as Taunt do nothing here.
|
||||
Clause::Keyword { .. } => open_trigger = false,
|
||||
}
|
||||
}
|
||||
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
|
||||
Card {
|
||||
def,
|
||||
triggers,
|
||||
auras,
|
||||
on_cast,
|
||||
in_pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_spell(&self) -> bool {
|
||||
self.def.card_type == CardType::Spell
|
||||
}
|
||||
|
||||
pub fn has_trigger(&self, on: TriggerOn) -> bool {
|
||||
self.triggers.iter().any(|t| t.on == on)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every card the game can create, looked up by name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CardLibrary {
|
||||
cards: HashMap<String, Card>,
|
||||
}
|
||||
|
||||
impl CardLibrary {
|
||||
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
|
||||
let mut library = CardLibrary::default();
|
||||
for def in defs {
|
||||
library.register(def);
|
||||
}
|
||||
library
|
||||
}
|
||||
|
||||
/// Add a card, or replace the card with the same name. It joins the Assemble pool
|
||||
/// if it is a minion with text.
|
||||
pub fn register(&mut self, def: CardDef) {
|
||||
self.insert(Card::compile(def, true));
|
||||
}
|
||||
|
||||
/// Add a card made by Assemble. It never joins the pool.
|
||||
pub fn register_assembled(&mut self, def: CardDef) {
|
||||
self.insert(Card::compile(def, false));
|
||||
}
|
||||
|
||||
fn insert(&mut self, card: Card) {
|
||||
self.cards.insert(card.def.name.clone(), card);
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.cards.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> &Card {
|
||||
self.cards
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
|
||||
}
|
||||
|
||||
/// The Assemble pool: every card with `in_pool`, sorted by name.
|
||||
pub fn pool(&self) -> Vec<&Card> {
|
||||
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
|
||||
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
|
||||
pool
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The Death Creation Step: the one place minions leave play.
|
||||
//!
|
||||
//! Damage and destroy effects only mark a minion (0 or less Health, or pending
|
||||
//! destroy). It stays in play, and keeps triggering, until the outermost Phase
|
||||
//! ends. Then this step removes every marked minion at once.
|
||||
|
||||
use super::event::Event;
|
||||
use super::world::World;
|
||||
|
||||
/// Remove every mortally wounded or pending-destroy minion from play, simultaneously.
|
||||
/// Returns their Death Events in order of play; empty if nothing died.
|
||||
pub fn death_creation_step(world: &mut World) -> Vec<Event> {
|
||||
// Every position is read before anything moves: removal is simultaneous, and each
|
||||
// Death Event remembers where its minion stood when the step began.
|
||||
let mut deaths = Vec::new();
|
||||
for id in world.minions_in_play() {
|
||||
if world.is_doomed(id) {
|
||||
let position = world
|
||||
.board_index(id)
|
||||
.expect("a minion in play is on the board");
|
||||
deaths.push(Event::Destroyed { id, position });
|
||||
}
|
||||
}
|
||||
for death in &deaths {
|
||||
if let Event::Destroyed { id, position } = *death {
|
||||
world.kill(id, position);
|
||||
}
|
||||
}
|
||||
deaths
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The interpreter: turns one effect, as data, into engine actions.
|
||||
//!
|
||||
//! This is the only place that knows what an effect *means*. A card's text is a
|
||||
//! small tree (clauses holding effects holding targets); the engine walks it. The
|
||||
//! effect vocabulary is closed: a card can only do what this match can do, which is
|
||||
//! what makes a data card safe to load.
|
||||
|
||||
use super::card::{Effect, Target};
|
||||
use super::queue::{Action, Ctx};
|
||||
use super::world::EntityId;
|
||||
|
||||
/// The actions one effect performs, in order, given where and for whom it runs.
|
||||
pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
|
||||
let owner = ctx.owner;
|
||||
match effect {
|
||||
Effect::DealDamage { target, amount } => vec![Action::Damage {
|
||||
targets: targets(*target, ctx),
|
||||
amount: *amount,
|
||||
}],
|
||||
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(),
|
||||
Effect::GainArmor { amount } => vec![Action::GainArmor {
|
||||
player: owner,
|
||||
amount: *amount,
|
||||
}],
|
||||
Effect::GainAttack { target, amount } => targets(*target, ctx)
|
||||
.into_iter()
|
||||
.map(|id| Action::GainAttack {
|
||||
id,
|
||||
amount: *amount,
|
||||
})
|
||||
.collect(),
|
||||
Effect::Destroy { target } => vec![Action::Destroy {
|
||||
targets: targets(*target, ctx),
|
||||
}],
|
||||
Effect::Assemble { filter } => vec![Action::Assemble {
|
||||
player: owner,
|
||||
filter: filter.clone(),
|
||||
}],
|
||||
Effect::BuffHand { attack, health } => ctx
|
||||
.world
|
||||
.minions_in_hand(owner)
|
||||
.into_iter()
|
||||
.map(|id| Action::Buff {
|
||||
id,
|
||||
attack: *attack,
|
||||
health: *health,
|
||||
})
|
||||
.collect(),
|
||||
Effect::SpawnText { .. } => panic!("spawnText needs a grid: it is a Part III effect"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entities a target names, from where the effect runs.
|
||||
fn targets(target: Target, ctx: &Ctx) -> Vec<EntityId> {
|
||||
let world = ctx.world;
|
||||
let others = |ids: Vec<EntityId>| -> Vec<EntityId> {
|
||||
ids.into_iter()
|
||||
.filter(|&id| Some(id) != ctx.source)
|
||||
.collect()
|
||||
};
|
||||
match target {
|
||||
Target::This => vec![ctx.minion()],
|
||||
Target::AllMinions => world.minions_in_play(),
|
||||
Target::AllOtherMinions => others(world.minions_in_play()),
|
||||
Target::AllOtherCharacters => {
|
||||
let mut ids = vec![world.hero(0), world.hero(1)];
|
||||
ids.extend(world.minions_in_play());
|
||||
others(ids)
|
||||
}
|
||||
Target::EnemyHero => vec![ctx.enemy_hero()],
|
||||
Target::Chosen => ctx.target.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a summon goes: to the right of a source still on the board; where a dead
|
||||
/// source stood (its Death Event's position); at the right end for a spell.
|
||||
fn summon_position(ctx: &Ctx) -> Option<usize> {
|
||||
match ctx.source.and_then(|s| ctx.world.board_index(s)) {
|
||||
Some(index) => Some(index + 1),
|
||||
None => ctx.position,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! What happened, in order. The engine records an event each time it changes the game.
|
||||
|
||||
use super::card::TriggerOn;
|
||||
use super::world::{EntityId, PlayerId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
Summoned {
|
||||
id: EntityId,
|
||||
},
|
||||
Damaged {
|
||||
target: EntityId,
|
||||
amount: i32,
|
||||
},
|
||||
Triggered {
|
||||
source: EntityId,
|
||||
on: TriggerOn,
|
||||
},
|
||||
Drew {
|
||||
player: PlayerId,
|
||||
id: EntityId,
|
||||
},
|
||||
Fatigue {
|
||||
player: PlayerId,
|
||||
},
|
||||
ArmorGained {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
StatsChanged {
|
||||
id: EntityId,
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
SpellCast {
|
||||
card: String,
|
||||
},
|
||||
/// A Death Event: the Death Creation Step moved `id` from play to the graveyard.
|
||||
/// `position` is the board index it had when the step began; its Deathrattle summons there.
|
||||
Destroyed {
|
||||
id: EntityId,
|
||||
position: usize,
|
||||
},
|
||||
DeathPhase {
|
||||
round: u32,
|
||||
},
|
||||
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
|
||||
Played {
|
||||
id: EntityId,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Engine core: generic rules machinery. Never name a specific card here.
|
||||
//!
|
||||
//! Module 1: every card in the game is an entity addressed by an id handle.
|
||||
//! The engine knows which zone each entity is in, who owns it, and when it
|
||||
//! entered play.
|
||||
//!
|
||||
//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
|
||||
//! action queue that resolves a Sequence depth-first, recording an event log.
|
||||
//!
|
||||
//! Module 3: deferred death. Damage and destroy effects only mark minions; the
|
||||
//! Death Creation Step removes them when the outermost Phase ends, and Death
|
||||
//! Phases resolve their Deathrattles until no new deaths occur.
|
||||
//!
|
||||
//! Module 4: auras as card data, Aura Updates at defined points, and one
|
||||
//! routine that decides how many times a queued trigger activates. Playing a
|
||||
//! minion from hand, and Battlecries.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
mod assemble;
|
||||
mod aura;
|
||||
mod card;
|
||||
mod death;
|
||||
mod effect;
|
||||
mod event;
|
||||
mod queue;
|
||||
mod world;
|
||||
|
||||
pub use assemble::{assemble, register_assembled, AssembleError, Half};
|
||||
pub use card::{
|
||||
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
|
||||
Trigger, TriggerOn,
|
||||
};
|
||||
pub use event::Event;
|
||||
pub use queue::{run_sequence, summon, Action, Ctx};
|
||||
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The action queue: how one player action (a Sequence) resolves.
|
||||
//!
|
||||
//! Performing an action changes the world and produces events. Every trigger that
|
||||
//! responds to those events goes into one Queue, oldest listener first, and the Queue
|
||||
//! is locked. Each queued trigger then resolves completely, including everything
|
||||
//! its own actions cause, before the next one starts: depth-first.
|
||||
//!
|
||||
//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
|
||||
//! Death Creation Step removes the dead, and a Death Phase resolves their
|
||||
//! Deathrattles. That repeats until a step finds no new deaths.
|
||||
//!
|
||||
//! Module 4: a queued trigger may activate more than once. How many times is
|
||||
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
|
||||
//! Nothing here knows which cards multiply which triggers.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use super::assemble::assemble_from_pool;
|
||||
use super::aura::{activations, aura_update_other};
|
||||
use super::card::{Effect, PoolFilter, TriggerOn};
|
||||
use super::death::death_creation_step;
|
||||
use super::effect::actions;
|
||||
use super::event::Event;
|
||||
use super::world::{EntityId, PlayerId, World, Zone};
|
||||
|
||||
/// Something the engine can do. Card effects return these instead of changing the world.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Deal `amount` to each target at once: all damage first, then the reactions.
|
||||
Damage {
|
||||
targets: Vec<EntityId>,
|
||||
amount: i32,
|
||||
},
|
||||
Draw {
|
||||
player: PlayerId,
|
||||
},
|
||||
GainArmor {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
GainAttack {
|
||||
id: EntityId,
|
||||
amount: i32,
|
||||
},
|
||||
Cast {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
target: Option<EntityId>,
|
||||
},
|
||||
/// Mark each target pending destroy. They leave play in the next Death Creation Step.
|
||||
Destroy {
|
||||
targets: Vec<EntityId>,
|
||||
},
|
||||
/// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`.
|
||||
Summon {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
at: Option<usize>,
|
||||
},
|
||||
/// Play a minion from its owner's hand: it enters the right end of the board, then its
|
||||
/// Battlecry resolves.
|
||||
Play {
|
||||
id: EntityId,
|
||||
},
|
||||
/// Put an Assembled minion into `player`'s hand (see `assemble.rs`).
|
||||
Assemble {
|
||||
player: PlayerId,
|
||||
filter: PoolFilter,
|
||||
},
|
||||
/// Give one entity +attack/+health.
|
||||
Buff {
|
||||
id: EntityId,
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an effect may look at when it runs.
|
||||
pub struct Ctx<'a> {
|
||||
pub world: &'a World,
|
||||
/// The player the effect works for.
|
||||
pub owner: PlayerId,
|
||||
/// The minion whose trigger is resolving; `None` for a spell.
|
||||
pub source: Option<EntityId>,
|
||||
/// The chosen target, if any.
|
||||
pub target: Option<EntityId>,
|
||||
/// The source's board position. For a Deathrattle, the source has left play,
|
||||
/// and this is the position its Death Event remembered.
|
||||
pub position: Option<usize>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// The minion whose trigger is resolving.
|
||||
pub fn minion(&self) -> EntityId {
|
||||
self.source.expect("only a triggered ability has a source")
|
||||
}
|
||||
|
||||
pub fn enemy_hero(&self) -> EntityId {
|
||||
self.world.hero(1 - self.owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one Sequence: a player action and everything it causes.
|
||||
pub fn run_sequence(world: &mut World, action: Action) {
|
||||
perform(world, action);
|
||||
// The outermost Phase has ended. Each Death Phase is itself an outermost
|
||||
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
|
||||
let mut deaths = end_of_outermost_phase(world);
|
||||
let mut round = 0;
|
||||
while !deaths.is_empty() {
|
||||
round += 1;
|
||||
world.record(Event::DeathPhase { round });
|
||||
react(world, &deaths);
|
||||
deaths = end_of_outermost_phase(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps that run only when the outermost Phase ends; returns the Death Events.
|
||||
/// Hearthstone runs an Aura Update (Health/Attack) first. The course has no
|
||||
/// Health/Attack auras, so that step has nothing to do and is left out.
|
||||
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
|
||||
let deaths = death_creation_step(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
aura_update_other(world);
|
||||
deaths
|
||||
}
|
||||
|
||||
/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an
|
||||
/// Aura Update.
|
||||
pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option<usize>) -> EntityId {
|
||||
let id = world.create_at(card, player, Zone::Board, at);
|
||||
aura_update_other(world);
|
||||
id
|
||||
}
|
||||
|
||||
fn perform(world: &mut World, action: Action) {
|
||||
match action {
|
||||
Action::Damage {
|
||||
mut targets,
|
||||
amount,
|
||||
} => {
|
||||
// Area damage: every Damage Event is created, in order of play, before any reaction.
|
||||
targets.sort_by_key(|&id| world.order_of_play(id));
|
||||
let mut events = Vec::new();
|
||||
for target in targets {
|
||||
world.damage(target, amount);
|
||||
events.push(Event::Damaged { target, amount });
|
||||
}
|
||||
react(world, &events);
|
||||
}
|
||||
Action::Draw { player } => world.draw(player),
|
||||
Action::GainArmor { player, amount } => world.gain_armor(player, amount),
|
||||
Action::GainAttack { id, amount } => world.gain_attack(id, amount),
|
||||
Action::Destroy { targets } => {
|
||||
for target in targets {
|
||||
world.mark_destroyed(target);
|
||||
}
|
||||
}
|
||||
Action::Summon { card, player, at } => {
|
||||
summon(world, &card, player, at);
|
||||
}
|
||||
Action::Play { id } => {
|
||||
assert!(
|
||||
world.zone_of(id) == Zone::Hand,
|
||||
"play: entity {id} is not in a hand"
|
||||
);
|
||||
// Playing is not summoning: no Aura Update here. The played minion's own
|
||||
// auras join at the end of the outermost Phase.
|
||||
world.move_to(id, Zone::Board);
|
||||
let played = Event::Played { id };
|
||||
world.record(played.clone());
|
||||
react(world, &[played]);
|
||||
}
|
||||
Action::Cast {
|
||||
card,
|
||||
player,
|
||||
target,
|
||||
} => {
|
||||
let spell = world.card(&card);
|
||||
assert!(spell.is_spell(), "{card:?} is not a spell");
|
||||
let effects = spell.on_cast.clone();
|
||||
world.record(Event::SpellCast { card });
|
||||
run_effects(world, &effects, player, None, target, None);
|
||||
}
|
||||
Action::Assemble { player, filter } => {
|
||||
assemble_from_pool(world, player, &filter);
|
||||
}
|
||||
Action::Buff { id, attack, health } => world.buff(id, attack, health),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret and perform an ability's effects in printed order. Each effect sees the
|
||||
/// world as the effects before it left it. Depth-first: each action, and every
|
||||
/// reaction it causes, finishes before the next.
|
||||
fn run_effects(
|
||||
world: &mut World,
|
||||
effects: &[Effect],
|
||||
owner: PlayerId,
|
||||
source: Option<EntityId>,
|
||||
target: Option<EntityId>,
|
||||
position: Option<usize>,
|
||||
) {
|
||||
for effect in effects {
|
||||
let ctx = Ctx {
|
||||
world,
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
position,
|
||||
};
|
||||
for action in actions(effect, &ctx) {
|
||||
perform(world, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Queue is now locked: nothing that happens below adds to it.
|
||||
for (source, 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);
|
||||
for _ in 0..times {
|
||||
world.record(Event::Triggered {
|
||||
source,
|
||||
on: trigger.on,
|
||||
});
|
||||
let position = match *event {
|
||||
Event::Destroyed { id, position } if id == source => Some(position),
|
||||
_ => world.board_index(source),
|
||||
};
|
||||
let owner = world.owner_of(source);
|
||||
run_effects(world, &trigger.effects, owner, Some(source), None, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who may respond to `event`, in order of play: every minion in play and, for a
|
||||
/// Death Event, the minion that died. It has left play, but its Deathrattle still answers.
|
||||
fn listeners(world: &World, event: &Event) -> Vec<EntityId> {
|
||||
let mut ids = world.minions_in_play();
|
||||
if let Event::Destroyed { id, .. } = *event {
|
||||
ids.push(id);
|
||||
ids.sort_by_key(|&id| world.order_of_play(id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`?
|
||||
fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool {
|
||||
if let Event::Destroyed { id, .. } = *event {
|
||||
return on == TriggerOn::Deathrattle && id == listener;
|
||||
}
|
||||
if let Event::Played { id } = *event {
|
||||
return on == TriggerOn::Battlecry && id == listener;
|
||||
}
|
||||
let Event::Damaged { target, .. } = *event else {
|
||||
return false;
|
||||
};
|
||||
let is_minion = !world.is_hero(target);
|
||||
match on {
|
||||
TriggerOn::SelfDamaged => target == listener,
|
||||
TriggerOn::FriendlyMinionDamaged => {
|
||||
is_minion && world.owner_of(target) == world.owner_of(listener)
|
||||
}
|
||||
TriggerOn::AnyMinionDamaged => is_minion,
|
||||
TriggerOn::HeroDamaged => !is_minion,
|
||||
TriggerOn::Deathrattle | TriggerOn::Battlecry | TriggerOn::EndOfTurn => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Entities, zones, order of play, stats and the event log: the game's state.
|
||||
//! The rules for resolving actions live in `queue.rs`.
|
||||
|
||||
use super::card::{Aura, Card, CardDef, CardLibrary};
|
||||
use super::event::Event;
|
||||
|
||||
/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone.
|
||||
pub type EntityId = u32;
|
||||
pub type PlayerId = u8;
|
||||
|
||||
/// Starting Health of each hero.
|
||||
pub const HERO_HEALTH: i32 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Zone {
|
||||
Deck,
|
||||
Hand,
|
||||
Board,
|
||||
Graveyard,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entity {
|
||||
card: String,
|
||||
owner: PlayerId,
|
||||
zone: Zone,
|
||||
/// Stamped each time the entity enters the board; `None` until it first does.
|
||||
order_of_play: Option<u32>,
|
||||
attack: i32,
|
||||
/// Current Health. At 0 or below the entity is mortally wounded: still in play until the
|
||||
/// Death Creation Step.
|
||||
health: i32,
|
||||
/// Hit by a destroy effect: still in play until the Death Creation Step.
|
||||
pending_destroy: bool,
|
||||
}
|
||||
|
||||
/// An aura in force: which player's minion has it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ActiveAura {
|
||||
pub owner: PlayerId,
|
||||
pub aura: Aura,
|
||||
}
|
||||
|
||||
/// One player's zones. Each is an ordered list of ids (the board is left to right).
|
||||
#[derive(Debug, Default)]
|
||||
struct Side {
|
||||
deck: Vec<EntityId>,
|
||||
hand: Vec<EntityId>,
|
||||
board: Vec<EntityId>,
|
||||
graveyard: Vec<EntityId>,
|
||||
hero: EntityId,
|
||||
armor: i32,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
|
||||
match zone {
|
||||
Zone::Deck => &self.deck,
|
||||
Zone::Hand => &self.hand,
|
||||
Zone::Board => &self.board,
|
||||
Zone::Graveyard => &self.graveyard,
|
||||
}
|
||||
}
|
||||
|
||||
fn zone_mut(&mut self, zone: Zone) -> &mut Vec<EntityId> {
|
||||
match zone {
|
||||
Zone::Deck => &mut self.deck,
|
||||
Zone::Hand => &mut self.hand,
|
||||
Zone::Board => &mut self.board,
|
||||
Zone::Graveyard => &mut self.graveyard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a player's choices: called with what is being chosen and the options, it
|
||||
/// returns one of the options. Hearthstone would ask the player or pick at random.
|
||||
pub type Chooser = Box<dyn FnMut(&str, &[String]) -> String>;
|
||||
|
||||
pub struct World {
|
||||
library: CardLibrary,
|
||||
chooser: Chooser,
|
||||
/// Indexed by `EntityId`.
|
||||
entities: Vec<Entity>,
|
||||
sides: [Side; 2],
|
||||
next_order_of_play: u32,
|
||||
log: Vec<Event>,
|
||||
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
|
||||
auras: Vec<ActiveAura>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// A new game: both heroes are in play from the start, so they are the oldest entities.
|
||||
pub fn new(library: CardLibrary) -> World {
|
||||
let mut world = World {
|
||||
library,
|
||||
// Until a chooser is set, take the first option.
|
||||
chooser: Box::new(|_, options| options[0].clone()),
|
||||
entities: Vec::new(),
|
||||
sides: Default::default(),
|
||||
next_order_of_play: 0,
|
||||
log: Vec::new(),
|
||||
auras: Vec::new(),
|
||||
};
|
||||
for owner in 0..2 {
|
||||
let id = world.entities.len() as EntityId;
|
||||
world.next_order_of_play += 1;
|
||||
world.entities.push(Entity {
|
||||
card: "Hero".to_string(),
|
||||
owner,
|
||||
zone: Zone::Board,
|
||||
order_of_play: Some(world.next_order_of_play),
|
||||
attack: 0,
|
||||
health: HERO_HEALTH,
|
||||
pending_destroy: false,
|
||||
});
|
||||
// In play, but not on the board list: the board holds minions only.
|
||||
world.sides[owner as usize].hero = id;
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
/// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board).
|
||||
pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId {
|
||||
self.create_at(card, owner, zone, None)
|
||||
}
|
||||
|
||||
/// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the
|
||||
/// zone's length), or at the end if `at` is `None`.
|
||||
pub fn create_at(
|
||||
&mut self,
|
||||
card: &str,
|
||||
owner: PlayerId,
|
||||
zone: Zone,
|
||||
at: Option<usize>,
|
||||
) -> EntityId {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
let def = &self.library.get(card).def;
|
||||
let entity = Entity {
|
||||
card: def.name.clone(),
|
||||
owner,
|
||||
zone,
|
||||
order_of_play: None,
|
||||
attack: def.attack,
|
||||
health: def.health,
|
||||
pending_destroy: false,
|
||||
};
|
||||
let id = self.entities.len() as EntityId;
|
||||
self.entities.push(entity);
|
||||
self.enter(id, zone, at);
|
||||
id
|
||||
}
|
||||
|
||||
/// Move an entity to the end of `zone` on its owner's side, keeping its id.
|
||||
pub fn move_to(&mut self, id: EntityId, zone: Zone) {
|
||||
let (owner, from) = (self.entity(id).owner, self.entity(id).zone);
|
||||
self.sides[owner as usize]
|
||||
.zone_mut(from)
|
||||
.retain(|&e| e != id);
|
||||
self.enter(id, zone, None);
|
||||
}
|
||||
|
||||
/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
|
||||
fn enter(&mut self, id: EntityId, zone: Zone, at: Option<usize>) {
|
||||
let owner = self.entity(id).owner;
|
||||
let list = self.sides[owner as usize].zone_mut(zone);
|
||||
list.insert(at.map_or(list.len(), |i| i.min(list.len())), id);
|
||||
let entity = &mut self.entities[id as usize];
|
||||
entity.zone = zone;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
fn entity(&self, id: EntityId) -> &Entity {
|
||||
self.entities
|
||||
.get(id as usize)
|
||||
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
||||
}
|
||||
|
||||
fn entity_mut(&mut self, id: EntityId) -> &mut Entity {
|
||||
self.entities
|
||||
.get_mut(id as usize)
|
||||
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
||||
}
|
||||
|
||||
// ---- Queries -------------------------------------------------------
|
||||
|
||||
pub fn card(&self, name: &str) -> &Card {
|
||||
self.library.get(name)
|
||||
}
|
||||
|
||||
pub fn has_card(&self, name: &str) -> bool {
|
||||
self.library.contains(name)
|
||||
}
|
||||
|
||||
/// The cards an Assemble may offer, sorted by name.
|
||||
pub fn pool(&self) -> Vec<&Card> {
|
||||
self.library.pool()
|
||||
}
|
||||
|
||||
pub fn zone_of(&self, id: EntityId) -> Zone {
|
||||
self.entity(id).zone
|
||||
}
|
||||
|
||||
pub fn card_name(&self, id: EntityId) -> &str {
|
||||
&self.entity(id).card
|
||||
}
|
||||
|
||||
pub fn owner_of(&self, id: EntityId) -> PlayerId {
|
||||
self.entity(id).owner
|
||||
}
|
||||
|
||||
/// When the entity last entered the board (larger is later); `None` if it never has.
|
||||
pub fn order_of_play(&self, id: EntityId) -> Option<u32> {
|
||||
self.entity(id).order_of_play
|
||||
}
|
||||
|
||||
/// The ids in one player's zone, in zone order (left to right for the board).
|
||||
pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
self.sides[owner as usize].zone(zone)
|
||||
}
|
||||
|
||||
pub fn hero(&self, owner: PlayerId) -> EntityId {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
self.sides[owner as usize].hero
|
||||
}
|
||||
|
||||
pub fn is_hero(&self, id: EntityId) -> bool {
|
||||
self.sides.iter().any(|s| s.hero == id)
|
||||
}
|
||||
|
||||
/// Every minion on either board, oldest first (order of play).
|
||||
pub fn minions_in_play(&self) -> Vec<EntityId> {
|
||||
let mut ids: Vec<EntityId> = self.sides.iter().flat_map(|s| s.board.clone()).collect();
|
||||
ids.sort_by_key(|&id| self.order_of_play(id));
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn attack(&self, id: EntityId) -> i32 {
|
||||
self.entity(id).attack
|
||||
}
|
||||
|
||||
pub fn health(&self, id: EntityId) -> i32 {
|
||||
self.entity(id).health
|
||||
}
|
||||
|
||||
/// The cost printed on the entity's card.
|
||||
pub fn cost(&self, id: EntityId) -> i32 {
|
||||
self.card(self.card_name(id)).def.cost
|
||||
}
|
||||
|
||||
/// The minions in a player's hand, in hand order.
|
||||
pub fn minions_in_hand(&self, owner: PlayerId) -> Vec<EntityId> {
|
||||
self.zone(owner, Zone::Hand)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| !self.card(self.card_name(id)).is_spell())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next
|
||||
/// Death Creation Step.
|
||||
pub fn is_doomed(&self, id: EntityId) -> bool {
|
||||
let entity = self.entity(id);
|
||||
entity.health <= 0 || entity.pending_destroy
|
||||
}
|
||||
|
||||
/// Index of a minion on its owner's board, left to right; `None` if it is not on the board.
|
||||
pub fn board_index(&self, id: EntityId) -> Option<usize> {
|
||||
self.zone(self.owner_of(id), Zone::Board)
|
||||
.iter()
|
||||
.position(|&e| e == id)
|
||||
}
|
||||
|
||||
pub fn armor(&self, owner: PlayerId) -> i32 {
|
||||
self.sides[owner as usize].armor
|
||||
}
|
||||
|
||||
pub fn active_auras(&self) -> &[ActiveAura] {
|
||||
&self.auras
|
||||
}
|
||||
|
||||
pub fn log(&self) -> &[Event] {
|
||||
&self.log
|
||||
}
|
||||
|
||||
// ---- Changes: each one records its event ----------------------------
|
||||
|
||||
pub fn record(&mut self, event: Event) {
|
||||
self.log.push(event);
|
||||
}
|
||||
|
||||
/// Add a card to the library, or replace the card with the same name.
|
||||
pub fn register(&mut self, def: CardDef) {
|
||||
self.library.register(def);
|
||||
}
|
||||
|
||||
/// Add a card made by Assemble (see `assemble.rs`).
|
||||
pub fn register_assembled(&mut self, def: CardDef) {
|
||||
self.library.register_assembled(def);
|
||||
}
|
||||
|
||||
pub fn set_chooser(&mut self, chooser: Chooser) {
|
||||
self.chooser = chooser;
|
||||
}
|
||||
|
||||
/// Ask the chooser to pick one of `options` (not empty). Panics if it answers
|
||||
/// something that isn't an option.
|
||||
pub fn choose(&mut self, kind: &str, options: &[String]) -> String {
|
||||
let answer = (self.chooser)(kind, options);
|
||||
assert!(
|
||||
options.contains(&answer),
|
||||
"chooser answered {answer:?} for {kind:?}; the options were {options:?}"
|
||||
);
|
||||
answer
|
||||
}
|
||||
|
||||
/// Only an Aura Update calls this.
|
||||
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
|
||||
self.auras = auras;
|
||||
}
|
||||
|
||||
pub fn clear_log(&mut self) {
|
||||
self.log.clear();
|
||||
}
|
||||
|
||||
pub fn damage(&mut self, target: EntityId, amount: i32) {
|
||||
self.entity_mut(target).health -= amount;
|
||||
self.record(Event::Damaged { target, amount });
|
||||
}
|
||||
|
||||
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
|
||||
/// records `Fatigue` and changes nothing else.
|
||||
pub fn draw(&mut self, player: PlayerId) {
|
||||
match self.zone(player, Zone::Deck).first() {
|
||||
Some(&id) => {
|
||||
self.move_to(id, Zone::Hand);
|
||||
self.record(Event::Drew { player, id });
|
||||
}
|
||||
None => self.record(Event::Fatigue { player }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark for destruction. Nothing leaves play until the Death Creation Step.
|
||||
pub fn mark_destroyed(&mut self, id: EntityId) {
|
||||
self.entity_mut(id).pending_destroy = true;
|
||||
}
|
||||
|
||||
/// Move a minion from play to its owner's graveyard, recording its Death Event.
|
||||
pub fn kill(&mut self, id: EntityId, position: usize) {
|
||||
self.entity_mut(id).pending_destroy = false;
|
||||
self.move_to(id, Zone::Graveyard);
|
||||
self.record(Event::Destroyed { id, position });
|
||||
}
|
||||
|
||||
pub fn gain_armor(&mut self, player: PlayerId, amount: i32) {
|
||||
self.sides[player as usize].armor += amount;
|
||||
self.record(Event::ArmorGained { player, amount });
|
||||
}
|
||||
|
||||
pub fn buff(&mut self, id: EntityId, attack: i32, health: i32) {
|
||||
let entity = self.entity_mut(id);
|
||||
entity.attack += attack;
|
||||
entity.health += health;
|
||||
let (attack, health) = (entity.attack, entity.health);
|
||||
self.record(Event::StatsChanged { id, attack, health });
|
||||
}
|
||||
|
||||
pub fn gain_attack(&mut self, id: EntityId, amount: i32) {
|
||||
let entity = self.entity_mut(id);
|
||||
entity.attack += amount;
|
||||
let (attack, health) = (entity.attack, entity.health);
|
||||
self.record(Event::StatsChanged { id, attack, health });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Test harness: the thin adapter the course tests call. It translates each
|
||||
//! call into engine operations and holds no rules of its own.
|
||||
|
||||
use crate::content;
|
||||
use crate::engine::{self, run_sequence, summon, Action, CardLibrary, Event, World};
|
||||
|
||||
pub use crate::engine::{AssembleError, CardDef, Half, Zone};
|
||||
|
||||
pub type Id = u32;
|
||||
pub type Player = u8;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GameOpts {
|
||||
pub decks: Option<[Vec<String>; 2]>,
|
||||
}
|
||||
|
||||
/// The events the tests read from `log()` (see course/harness.md).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GameEvent {
|
||||
Summoned { id: Id },
|
||||
Damaged { target: Id, amount: i32 },
|
||||
Triggered { source: Id, trigger: String },
|
||||
Destroyed { id: Id },
|
||||
DeathPhase { round: u32 },
|
||||
Drew { player: Player, id: Id },
|
||||
Fatigue { player: Player },
|
||||
ArmorGained { player: Player, amount: i32 },
|
||||
StatsChanged { id: Id, attack: i32, health: i32 },
|
||||
SpellCast { card: String },
|
||||
}
|
||||
|
||||
impl GameEvent {
|
||||
/// The course's view of an engine event; `None` for engine events the course log
|
||||
/// doesn't show.
|
||||
fn from_engine(event: &Event) -> Option<GameEvent> {
|
||||
Some(match event.clone() {
|
||||
Event::Summoned { id } => GameEvent::Summoned { id },
|
||||
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
|
||||
Event::Triggered { source, on } => GameEvent::Triggered {
|
||||
source,
|
||||
trigger: on.label().to_string(),
|
||||
},
|
||||
Event::Drew { player, id } => GameEvent::Drew { player, id },
|
||||
Event::Fatigue { player } => GameEvent::Fatigue { player },
|
||||
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
|
||||
Event::StatsChanged { id, attack, health } => {
|
||||
GameEvent::StatsChanged { id, attack, health }
|
||||
}
|
||||
Event::SpellCast { card } => GameEvent::SpellCast { card },
|
||||
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
|
||||
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
Event::Played { .. } => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
world: World,
|
||||
}
|
||||
|
||||
pub fn new_game(opts: GameOpts) -> Game {
|
||||
let mut world = World::new(CardLibrary::new(content::cards()));
|
||||
if let Some(decks) = opts.decks {
|
||||
for (player, deck) in decks.iter().enumerate() {
|
||||
for card in deck {
|
||||
world.create(card, player as Player, Zone::Deck);
|
||||
}
|
||||
}
|
||||
}
|
||||
Game { world }
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Put `card` at the right end of `player`'s board.
|
||||
pub fn summon(&mut self, card: &str, player: Player) -> Id {
|
||||
summon(&mut self.world, card, player, None)
|
||||
}
|
||||
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
|
||||
self.world.create(card, player, Zone::Hand)
|
||||
}
|
||||
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
|
||||
self.world.create(card, player, Zone::Deck)
|
||||
}
|
||||
/// Play a card from hand, as one Sequence: it enters the right end of the board, then
|
||||
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
|
||||
pub fn play(&mut self, hand_id: Id) {
|
||||
run_sequence(&mut self.world, Action::Play { id: hand_id });
|
||||
}
|
||||
pub fn zone_of(&self, id: Id) -> Zone {
|
||||
self.world.zone_of(id)
|
||||
}
|
||||
/// Board ids, left to right.
|
||||
pub fn board(&self, player: Player) -> Vec<Id> {
|
||||
self.world.zone(player, Zone::Board).to_vec()
|
||||
}
|
||||
pub fn hand(&self, player: Player) -> Vec<Id> {
|
||||
self.world.zone(player, Zone::Hand).to_vec()
|
||||
}
|
||||
pub fn order_of_play(&self, id: Id) -> u32 {
|
||||
self.world
|
||||
.order_of_play(id)
|
||||
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
|
||||
}
|
||||
pub fn name_of(&self, id: Id) -> String {
|
||||
self.world.card_name(id).to_string()
|
||||
}
|
||||
|
||||
// ---- Module 2 ----
|
||||
|
||||
pub fn hero(&self, player: Player) -> Id {
|
||||
self.world.hero(player)
|
||||
}
|
||||
/// Deal damage to one character, as one Sequence.
|
||||
pub fn deal_damage(&mut self, target: Id, amount: i32) {
|
||||
let targets = vec![target];
|
||||
run_sequence(&mut self.world, Action::Damage { targets, amount });
|
||||
}
|
||||
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
|
||||
let card = card.to_string();
|
||||
run_sequence(
|
||||
&mut self.world,
|
||||
Action::Cast {
|
||||
card,
|
||||
player,
|
||||
target,
|
||||
},
|
||||
);
|
||||
}
|
||||
pub fn health(&self, id: Id) -> i32 {
|
||||
self.world.health(id)
|
||||
}
|
||||
pub fn attack(&self, id: Id) -> i32 {
|
||||
self.world.attack(id)
|
||||
}
|
||||
pub fn armor(&self, player: Player) -> i32 {
|
||||
self.world.armor(player)
|
||||
}
|
||||
pub fn log(&self) -> Vec<GameEvent> {
|
||||
self.world
|
||||
.log()
|
||||
.iter()
|
||||
.filter_map(GameEvent::from_engine)
|
||||
.collect()
|
||||
}
|
||||
pub fn clear_log(&mut self) {
|
||||
self.world.clear_log();
|
||||
}
|
||||
|
||||
// ---- Module 3 ----
|
||||
|
||||
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
|
||||
pub fn destroy(&mut self, target: Id) {
|
||||
let targets = vec![target];
|
||||
run_sequence(&mut self.world, Action::Destroy { targets });
|
||||
}
|
||||
|
||||
// ---- Module 5 ----
|
||||
|
||||
/// Add card definitions to this game, replacing any with the same name.
|
||||
pub fn register_cards(&mut self, defs: Vec<CardDef>) {
|
||||
for def in defs {
|
||||
self.world.register(def);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `base` with `half` of its text replaced by the same half of `donor`,
|
||||
/// and return the new card's name, `"<base> (Assembled)"`.
|
||||
pub fn assemble(
|
||||
&mut self,
|
||||
base: &str,
|
||||
donor: &str,
|
||||
half: Half,
|
||||
) -> Result<String, AssembleError> {
|
||||
let base = &self.world.card(base).def;
|
||||
let donor = &self.world.card(donor).def;
|
||||
let def = engine::assemble(base, donor, half)?;
|
||||
Ok(engine::register_assembled(&mut self.world, def))
|
||||
}
|
||||
|
||||
pub fn cost(&self, id: Id) -> i32 {
|
||||
self.world.cost(id)
|
||||
}
|
||||
|
||||
/// Who makes choices (Assemble's base, donor and half): called with the kind of
|
||||
/// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them.
|
||||
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
|
||||
self.world.set_chooser(Box::new(chooser));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod content;
|
||||
pub mod engine;
|
||||
pub mod harness;
|
||||
@@ -0,0 +1 @@
|
||||
{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "05" }
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,16 @@
|
||||
# Module 5 reference engine (TypeScript)
|
||||
|
||||
The course's own engine as of the end of Module 5. Every card is now data: a JSON file in the course's card format (`course/schema/card.schema.json`), and the functions that were card effects in Modules 2–4 are gone. One interpreter turns effects into actions. Assemble builds a new card by replacing half of one card's text with the same half of another's. Module 4's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` (at the package root; not in this directory): the card data. It is a copy of `course/cards/`, the single source: `tools/verify-references.mjs` stages it next to the package, and `tools/start-module.mjs` copies it into a learner's workspace.
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `card.ts`: the card format as TypeScript types (`CardDef`, `Clause`, `Effect`, `Target`, `Aura`), and the library. Registering a definition compiles its clauses into triggers, auras and a spell's effects, and marks whether Assemble may offer it (minions with text that were not themselves assembled). `TriggerOn` gains `endOfTurn`, which card data may name (Drakkari Enchanter's aura does); the course has no turns, so nothing fires it.
|
||||
- `effect.ts`: the interpreter: one effect plus its context in, actions out. The only code that knows what `dealDamage`, `draw`, `summon`, `assemble`, `buffHand`, ... mean.
|
||||
- `assemble.ts`: `assemble(base, donor, half)` (list surgery on `text` at `split`; throws `AssembleError` if the text is unchanged), and the `assemble` effect, which asks the chooser for a base from the filtered pool, a donor and a half.
|
||||
- `queue.ts`: as in Module 4, plus two actions (`assemble`, `buff`). An ability's effects are interpreted and performed one at a time, in printed order.
|
||||
- `world.ts`: as in Module 4, plus card registration, cost, the chooser, and `buff`.
|
||||
- `aura.ts`, `death.ts`, `event.ts`: unchanged from Module 4.
|
||||
- `src/content/index.ts`: imports every `.json` file in `cards/` with Vite's `import.meta.glob`. No card is named in TypeScript any more.
|
||||
- `src/harness.ts`: the adapter the course tests call. Module 5 adds `registerCards`, `assemble`, `cost` and `setChooser`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 05` from the course repository. `npm test` in this directory alone fails: there is no `cards/` here.
|
||||
+1496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "systemic-design-workspace",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite/client" />
|
||||
// Content: the cards. Only this directory (and the card data in `cards/`) may name
|
||||
// specific cards.
|
||||
//
|
||||
// From Module 5 every card is a JSON file in `cards/` at the package root, in the
|
||||
// course's card format (`course/schema/card.schema.json`). The functions that were
|
||||
// card effects in Modules 2–4 are gone: the engine interprets the data.
|
||||
// `start-module` copies the course's card files into `cards/`; add your own there.
|
||||
//
|
||||
// `import.meta.glob` is Vite's (Vitest runs on Vite): it imports every matching
|
||||
// JSON file when this module loads, so no file system code is needed.
|
||||
|
||||
import type { CardDef } from "../engine";
|
||||
|
||||
const files = import.meta.glob<CardDef>("../../cards/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** Every card in `cards/`, in file-name order. */
|
||||
export function cards(): CardDef[] {
|
||||
return Object.keys(files)
|
||||
.sort()
|
||||
.map((path) => files[path]);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Assemble: "Get a random minion. Change half its text."
|
||||
//
|
||||
// Card text is a list of clauses, so changing half of it is list surgery: the base
|
||||
// card's `text.slice(0, split)` (top) or `text.slice(split)` (bottom) is replaced by
|
||||
// the same half of a donor card. Name, cost and stats stay the base's. This works
|
||||
// only because a card's behaviour is its data: the new card needs no new code.
|
||||
//
|
||||
// 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 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. */
|
||||
export class AssembleError extends Error {
|
||||
constructor(
|
||||
readonly base: string,
|
||||
readonly donor: string,
|
||||
readonly half: Half,
|
||||
) {
|
||||
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
|
||||
this.name = "AssembleError";
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
|
||||
const canonical = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, v) =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
: v,
|
||||
);
|
||||
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
|
||||
|
||||
/**
|
||||
* The base card with `half` of its text replaced by the same half of `donor`.
|
||||
* It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
||||
*/
|
||||
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
|
||||
const topOf = (c: CardDef) => c.text.slice(0, c.split);
|
||||
const bottomOf = (c: CardDef) => c.text.slice(c.split);
|
||||
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);
|
||||
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
|
||||
}
|
||||
|
||||
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
|
||||
try {
|
||||
assemble(base, donor, half);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AssembleError) return false;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register an assembled card and return its name. If a different card already has
|
||||
* the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
||||
*/
|
||||
export function registerAssembled(world: World, def: CardDef): string {
|
||||
const stem = def.name.replace(/\)$/, "");
|
||||
let name = def.name;
|
||||
// Same base, so the same name and stats: only the text can differ.
|
||||
for (let n = 2; world.hasCard(name) && !sameText(world.card(name).def.text, def.text); n++) {
|
||||
name = `${stem} ${n})`;
|
||||
}
|
||||
world.registerAssembled({ ...def, name });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
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 half = world.choose(
|
||||
"half",
|
||||
HALVES.filter((h) => changes(base, donor, h)),
|
||||
) as Half;
|
||||
const name = registerAssembled(world, assemble(base, donor, half));
|
||||
return world.create(name, player, "hand");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Auras, and the one routine that applies trigger multipliers.
|
||||
//
|
||||
// An aura is data on a card: a continuous effect its minion has while in play.
|
||||
// The engine never asks "is that card on the board?" in the middle of resolving
|
||||
// something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
// Update) and every rule reads the snapshot.
|
||||
//
|
||||
// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
// Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
// Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
// Summoning a minion also runs an Aura Update.
|
||||
|
||||
import type { TriggerOn } from "./card";
|
||||
import type { PlayerId, World } from "./world";
|
||||
|
||||
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
|
||||
export function auraUpdateOther(world: World): void {
|
||||
world.setActiveAuras(
|
||||
world.minionsInPlay().flatMap((id) =>
|
||||
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
* force the answer is 1. Multipliers with `stacking: "max"` contribute their largest
|
||||
* `times`; each `"additive"` one adds `times - 1` extra activations.
|
||||
*/
|
||||
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
|
||||
let max = 1;
|
||||
let extra = 0;
|
||||
for (const active of world.activeAuras()) {
|
||||
const { trigger, times, stacking } = active.aura;
|
||||
if (active.owner !== owner || trigger !== on) continue;
|
||||
if (stacking === "max") max = Math.max(max, times);
|
||||
else extra += times - 1;
|
||||
}
|
||||
return max + extra;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
||||
// read straight from JSON. The engine compiles each definition once, when it is
|
||||
// registered, into the lists it reads while resolving: triggers, auras and a
|
||||
// spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
||||
|
||||
/** What a triggered ability listens for. */
|
||||
export type TriggerOn =
|
||||
| "selfDamaged" // this minion takes damage
|
||||
| "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage
|
||||
| "anyMinionDamaged" // any minion takes damage
|
||||
| "heroDamaged" // either hero takes damage
|
||||
| "deathrattle" // this minion dies (it resolves after the minion has left play)
|
||||
| "battlecry" // this minion is played from hand
|
||||
| "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires
|
||||
|
||||
/** The trigger's family name, as reported in `Triggered` events. */
|
||||
export function triggerLabel(on: TriggerOn): string {
|
||||
switch (on) {
|
||||
case "selfDamaged":
|
||||
case "friendlyMinionDamaged":
|
||||
case "anyMinionDamaged":
|
||||
case "heroDamaged":
|
||||
return "damaged";
|
||||
case "deathrattle":
|
||||
case "battlecry":
|
||||
case "endOfTurn":
|
||||
return on;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
* auras only at the Aura Update points (see `aura.ts`), never mid-Phase.
|
||||
*
|
||||
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
|
||||
* the aura owner's owner, activate `times` times each. `stacking` says how several
|
||||
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
|
||||
* a doubler still double); `"additive"` adds each copy's extra activations.
|
||||
*/
|
||||
export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
|
||||
|
||||
/** Who an effect touches. */
|
||||
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
|
||||
|
||||
/** Which minions an Assemble may pick as its base. An empty filter allows any. */
|
||||
export interface PoolFilter {
|
||||
/** Only minions with a trigger clause listening for this. */
|
||||
trigger?: TriggerOn;
|
||||
}
|
||||
|
||||
/** One effect: a node of a card's ability, interpreted by `effect.ts`. */
|
||||
export type Effect =
|
||||
| { type: "dealDamage"; target: Target; amount: number }
|
||||
| { type: "draw"; count: number }
|
||||
| { type: "summon"; card: string; count: number }
|
||||
| { type: "gainArmor"; amount: number }
|
||||
| { type: "gainAttack"; target: Target; amount: number }
|
||||
| { type: "destroy"; target: Target }
|
||||
/** Part III (SpellGrid) only: puts a word on the grid. */
|
||||
| { type: "spawnText"; word: string; at: "self" }
|
||||
/** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */
|
||||
| { type: "assemble"; filter: PoolFilter }
|
||||
/** Give every minion in the owner's hand +attack/+health. */
|
||||
| { type: "buffHand"; attack: number; health: number };
|
||||
|
||||
/**
|
||||
* One line of card text. A trigger clause applies to the effect clauses after it,
|
||||
* until the next trigger, keyword or aura. Effects before any trigger are what a
|
||||
* spell does when cast.
|
||||
*/
|
||||
export type Clause =
|
||||
| { kind: "keyword"; keyword: string }
|
||||
| { kind: "trigger"; on: TriggerOn }
|
||||
| { kind: "aura"; modifier: Aura }
|
||||
| { kind: "effect"; effect: Effect };
|
||||
|
||||
/** A card as data, in the course's card format. */
|
||||
export interface CardDef {
|
||||
name: string;
|
||||
type: "minion" | "spell";
|
||||
cost: number;
|
||||
attack?: number;
|
||||
health?: number;
|
||||
noun?: string;
|
||||
text: Clause[];
|
||||
/** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */
|
||||
split: number;
|
||||
fictional?: boolean;
|
||||
game?: string;
|
||||
}
|
||||
|
||||
/** A triggered ability: "whenever <on>, <effects>". */
|
||||
export interface Trigger {
|
||||
on: TriggerOn;
|
||||
effects: Effect[];
|
||||
}
|
||||
|
||||
/** A registered card: its definition, and the definition's text compiled into the lists the engine reads. */
|
||||
export interface Card {
|
||||
def: CardDef;
|
||||
/** Triggered abilities, in printed order. */
|
||||
triggers: Trigger[];
|
||||
/** Auras this minion has while in play. */
|
||||
auras: Aura[];
|
||||
/** What the card does when cast (a spell's effects). */
|
||||
onCast: Effect[];
|
||||
/** May an Assemble offer this card? Minions with text that were not themselves assembled. */
|
||||
inPool: boolean;
|
||||
}
|
||||
|
||||
/** Compile a definition's text. Throws if `split` is past the end of the text. */
|
||||
export function compile(def: CardDef, inPool: boolean): Card {
|
||||
if (def.split > def.text.length) throw new Error(`"${def.name}": split ${def.split} is past the end of its text`);
|
||||
const card: Card = { def, triggers: [], auras: [], onCast: [], inPool: false };
|
||||
// The trigger whose effects are still being listed, if any.
|
||||
let open: Trigger | undefined;
|
||||
for (const clause of def.text) {
|
||||
switch (clause.kind) {
|
||||
case "trigger":
|
||||
open = { on: clause.on, effects: [] };
|
||||
card.triggers.push(open);
|
||||
break;
|
||||
case "effect":
|
||||
(open ? open.effects : card.onCast).push(clause.effect);
|
||||
break;
|
||||
case "aura":
|
||||
card.auras.push(clause.modifier);
|
||||
open = undefined;
|
||||
break;
|
||||
case "keyword": // The course has no combat, so keywords such as Taunt do nothing here.
|
||||
open = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
card.inPool = inPool && def.type === "minion" && def.text.length > 0;
|
||||
return card;
|
||||
}
|
||||
|
||||
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
|
||||
|
||||
/** Every card the game can create, looked up by name. */
|
||||
export class CardLibrary {
|
||||
private readonly cards = new Map<string, Card>();
|
||||
|
||||
constructor(defs: CardDef[]) {
|
||||
for (const def of defs) this.register(def);
|
||||
}
|
||||
|
||||
/** Add a card, or replace the card with the same name. It joins the Assemble pool if it is a minion with text. */
|
||||
register(def: CardDef): void {
|
||||
this.cards.set(def.name, compile(def, true));
|
||||
}
|
||||
|
||||
/** Add a card made by Assemble. It never joins the pool. */
|
||||
registerAssembled(def: CardDef): void {
|
||||
this.cards.set(def.name, compile(def, false));
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.cards.has(name);
|
||||
}
|
||||
|
||||
get(name: string): Card {
|
||||
const card = this.cards.get(name);
|
||||
if (!card) throw new Error(`unknown card "${name}": add its file to cards/`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** The Assemble pool: every card with `inPool`, sorted by name. */
|
||||
pool(): Card[] {
|
||||
return [...this.cards.values()]
|
||||
.filter((c) => c.inPool)
|
||||
.sort((a, b) => (a.def.name < b.def.name ? -1 : a.def.name > b.def.name ? 1 : 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The Death Creation Step: the one place minions leave play.
|
||||
//
|
||||
// Damage and destroy effects only mark a minion (0 or less Health, or pending
|
||||
// destroy). It stays in play, and keeps triggering, until the outermost Phase
|
||||
// ends. Then this step removes every marked minion at once.
|
||||
|
||||
import type { Event } from "./event";
|
||||
import type { World } from "./world";
|
||||
|
||||
/**
|
||||
* Remove every mortally wounded or pending-destroy minion from play, simultaneously.
|
||||
* Returns their Death Events in order of play; empty if nothing died.
|
||||
*/
|
||||
export function deathCreationStep(world: World): Event[] {
|
||||
const dying = world.minionsInPlay().filter((id) => world.isDoomed(id));
|
||||
// Every position is read before anything moves: removal is simultaneous, and each
|
||||
// Death Event remembers where its minion stood when the step began.
|
||||
const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! }));
|
||||
for (const { id, position } of deaths) world.kill(id, position);
|
||||
return deaths;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// The interpreter: turns one effect, as data, into engine actions.
|
||||
//
|
||||
// This is the only place that knows what an effect *means*. A card's text is a
|
||||
// small tree (clauses holding effects holding targets); the engine walks it. The
|
||||
// effect vocabulary is closed: a card can only do what this switch can do, which is
|
||||
// what makes a data card safe to load.
|
||||
|
||||
import type { Effect, Target } from "./card";
|
||||
import type { Action, Ctx } from "./queue";
|
||||
import type { EntityId } from "./world";
|
||||
|
||||
const times = <T>(count: number, make: () => T): T[] => Array.from({ length: count }, make);
|
||||
|
||||
/** The actions one effect performs, in order, given where and for whom it runs. */
|
||||
export function actions(effect: Effect, ctx: Ctx): Action[] {
|
||||
const player = ctx.owner;
|
||||
switch (effect.type) {
|
||||
case "dealDamage":
|
||||
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 "gainArmor":
|
||||
return [{ type: "gainArmor", player, amount: effect.amount }];
|
||||
case "gainAttack":
|
||||
return targets(effect.target, ctx).map((id) => ({ type: "gainAttack", id, amount: effect.amount }));
|
||||
case "destroy":
|
||||
return [{ type: "destroy", targets: targets(effect.target, ctx) }];
|
||||
case "assemble":
|
||||
return [{ type: "assemble", player, filter: effect.filter }];
|
||||
case "buffHand":
|
||||
return ctx.world
|
||||
.minionsInHand(player)
|
||||
.map((id) => ({ type: "buff", id, attack: effect.attack, health: effect.health }));
|
||||
case "spawnText":
|
||||
throw new Error("spawnText needs a grid: it is a Part III effect");
|
||||
}
|
||||
}
|
||||
|
||||
/** The entities a target names, from where the effect runs. */
|
||||
function targets(target: Target, ctx: Ctx): EntityId[] {
|
||||
const { world } = ctx;
|
||||
const others = (ids: EntityId[]) => ids.filter((id) => id !== ctx.source);
|
||||
switch (target) {
|
||||
case "self":
|
||||
return [ctx.minion()];
|
||||
case "allMinions":
|
||||
return world.minionsInPlay();
|
||||
case "allOtherMinions":
|
||||
return others(world.minionsInPlay());
|
||||
case "allOtherCharacters":
|
||||
return others([world.hero(0), world.hero(1), ...world.minionsInPlay()]);
|
||||
case "enemyHero":
|
||||
return [ctx.enemyHero()];
|
||||
case "chosen":
|
||||
return ctx.target === undefined ? [] : [ctx.target];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a summon goes: to the right of a source still on the board; where a dead
|
||||
* source stood (its Death Event's position); at the right end for a spell.
|
||||
*/
|
||||
function summonPosition(ctx: Ctx): number | undefined {
|
||||
const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source);
|
||||
return index === undefined ? ctx.position : index + 1;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// What happened, in order. The engine records an event each time it changes the game.
|
||||
|
||||
import type { TriggerOn } from "./card";
|
||||
import type { EntityId, PlayerId } from "./world";
|
||||
|
||||
export type Event =
|
||||
| { type: "Summoned"; id: EntityId }
|
||||
| { type: "Damaged"; target: EntityId; amount: number }
|
||||
| { type: "Triggered"; source: EntityId; on: TriggerOn }
|
||||
| { type: "Drew"; player: PlayerId; id: EntityId }
|
||||
| { type: "Fatigue"; player: PlayerId }
|
||||
| { type: "ArmorGained"; player: PlayerId; amount: number }
|
||||
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
|
||||
| { type: "SpellCast"; card: string }
|
||||
/**
|
||||
* A Death Event: the Death Creation Step moved `id` from play to the graveyard.
|
||||
* `position` is the board index it had when the step began; its Deathrattle summons there.
|
||||
*/
|
||||
| { type: "Destroyed"; id: EntityId; position: number }
|
||||
| { type: "DeathPhase"; round: number }
|
||||
/** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */
|
||||
| { type: "Played"; id: EntityId };
|
||||
@@ -0,0 +1,38 @@
|
||||
// Engine core: generic rules machinery. Never name a specific card here.
|
||||
//
|
||||
// Module 1: every card in the game is an entity addressed by an id handle.
|
||||
// The engine knows which zone each entity is in, who owns it, and when it
|
||||
// entered play.
|
||||
//
|
||||
// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
|
||||
// action queue that resolves a Sequence depth-first, recording an event log.
|
||||
//
|
||||
// Module 3: deferred death. Damage and destroy effects only mark minions; the
|
||||
// Death Creation Step removes them when the outermost Phase ends, and Death
|
||||
// Phases resolve their Deathrattles until no new deaths occur.
|
||||
//
|
||||
// Module 4: auras as card data, Aura Updates at defined points, and one
|
||||
// routine that decides how many times a queued trigger activates. Playing a
|
||||
// minion from hand, and Battlecries.
|
||||
//
|
||||
// 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.
|
||||
|
||||
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
|
||||
export {
|
||||
CardLibrary,
|
||||
triggerLabel,
|
||||
type Aura,
|
||||
type Card,
|
||||
type CardDef,
|
||||
type Clause,
|
||||
type Effect,
|
||||
type PoolFilter,
|
||||
type Target,
|
||||
type Trigger,
|
||||
type TriggerOn,
|
||||
} from "./card";
|
||||
export type { Event } from "./event";
|
||||
export { Ctx, runSequence, summon, type Action } from "./queue";
|
||||
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -0,0 +1,227 @@
|
||||
// The action queue: how one player action (a Sequence) resolves.
|
||||
//
|
||||
// Performing an action changes the world and produces events. Every trigger that
|
||||
// responds to those events goes into one Queue, oldest listener first, and the Queue
|
||||
// is locked. Each queued trigger then resolves completely, including everything
|
||||
// its own actions cause, before the next one starts: depth-first.
|
||||
//
|
||||
// Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
|
||||
// Death Creation Step removes the dead, and a Death Phase resolves their
|
||||
// Deathrattles. That repeats until a step finds no new deaths.
|
||||
//
|
||||
// Module 4: a queued trigger may activate more than once. How many times is
|
||||
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
|
||||
// Nothing here knows which cards multiply which triggers.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { assembleFromPool } from "./assemble";
|
||||
import { activations, auraUpdateOther } from "./aura";
|
||||
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
|
||||
import { deathCreationStep } from "./death";
|
||||
import { actions } from "./effect";
|
||||
import type { Event } from "./event";
|
||||
import type { EntityId, PlayerId, World } from "./world";
|
||||
|
||||
/** Something the engine can do. Card effects return these instead of changing the world. */
|
||||
export type Action =
|
||||
/** Deal `amount` to each target at once: all damage first, then the reactions. */
|
||||
| { type: "damage"; targets: EntityId[]; amount: number }
|
||||
| { type: "draw"; player: PlayerId }
|
||||
| { type: "gainArmor"; player: PlayerId; amount: number }
|
||||
| { type: "gainAttack"; id: EntityId; amount: number }
|
||||
| { type: "cast"; card: string; player: PlayerId; target?: EntityId }
|
||||
/** Mark each target pending destroy. They leave play in the next Death Creation Step. */
|
||||
| { type: "destroy"; targets: EntityId[] }
|
||||
/** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */
|
||||
| { type: "summon"; card: string; player: PlayerId; at?: number }
|
||||
/** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */
|
||||
| { type: "play"; id: EntityId }
|
||||
/** Put an Assembled minion into `player`'s hand (see `assemble.ts`). */
|
||||
| { type: "assemble"; player: PlayerId; filter: PoolFilter }
|
||||
/** Give one entity +attack/+health. */
|
||||
| { type: "buff"; id: EntityId; attack: number; health: number };
|
||||
|
||||
/** What an effect may look at when it runs. */
|
||||
export class Ctx {
|
||||
constructor(
|
||||
readonly world: World,
|
||||
/** The player the effect works for. */
|
||||
readonly owner: PlayerId,
|
||||
/** The minion whose trigger is resolving; undefined for a spell. */
|
||||
readonly source?: EntityId,
|
||||
/** The chosen target, if any. */
|
||||
readonly target?: EntityId,
|
||||
/**
|
||||
* The source's board position. For a Deathrattle, the source has left play,
|
||||
* and this is the position its Death Event remembered.
|
||||
*/
|
||||
readonly position?: number,
|
||||
) {}
|
||||
|
||||
/** The minion whose trigger is resolving. */
|
||||
minion(): EntityId {
|
||||
if (this.source === undefined) throw new Error("only a triggered ability has a source");
|
||||
return this.source;
|
||||
}
|
||||
|
||||
enemyHero(): EntityId {
|
||||
return this.world.hero(this.owner === 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one Sequence: a player action and everything it causes. */
|
||||
export function runSequence(world: World, action: Action): void {
|
||||
perform(world, action);
|
||||
// The outermost Phase has ended. Each Death Phase is itself an outermost
|
||||
// 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++) {
|
||||
world.record({ type: "DeathPhase", round });
|
||||
react(world, deaths);
|
||||
deaths = endOfOutermostPhase(world);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The steps that run only when the outermost Phase ends; returns the Death Events.
|
||||
* Hearthstone runs an Aura Update (Health/Attack) first. The course has no
|
||||
* Health/Attack auras, so that step has nothing to do and is left out.
|
||||
*/
|
||||
function endOfOutermostPhase(world: World): Event[] {
|
||||
const deaths = deathCreationStep(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
auraUpdateOther(world);
|
||||
return deaths;
|
||||
}
|
||||
|
||||
/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */
|
||||
export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId {
|
||||
const id = world.create(card, player, "board", at);
|
||||
auraUpdateOther(world);
|
||||
return id;
|
||||
}
|
||||
|
||||
function perform(world: World, action: Action): void {
|
||||
switch (action.type) {
|
||||
case "damage": {
|
||||
// Area damage: every Damage Event is created, in order of play, before any reaction.
|
||||
const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
|
||||
const events: Event[] = [];
|
||||
for (const target of targets) {
|
||||
world.damage(target, action.amount);
|
||||
events.push({ type: "Damaged", target, amount: action.amount });
|
||||
}
|
||||
react(world, events);
|
||||
break;
|
||||
}
|
||||
case "draw":
|
||||
world.draw(action.player);
|
||||
break;
|
||||
case "gainArmor":
|
||||
world.gainArmor(action.player, action.amount);
|
||||
break;
|
||||
case "gainAttack":
|
||||
world.gainAttack(action.id, action.amount);
|
||||
break;
|
||||
case "destroy":
|
||||
for (const target of action.targets) world.markDestroyed(target);
|
||||
break;
|
||||
case "summon":
|
||||
summon(world, action.card, action.player, action.at);
|
||||
break;
|
||||
case "play": {
|
||||
if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`);
|
||||
// Playing is not summoning: no Aura Update here. The played minion's own
|
||||
// auras join at the end of the outermost Phase.
|
||||
world.moveTo(action.id, "board");
|
||||
const played: Event = { type: "Played", id: action.id };
|
||||
world.record(played);
|
||||
react(world, [played]);
|
||||
break;
|
||||
}
|
||||
case "cast": {
|
||||
const spell = world.card(action.card);
|
||||
if (spell.def.type !== "spell") throw new Error(`"${action.card}" is not a spell`);
|
||||
world.record({ type: "SpellCast", card: action.card });
|
||||
runEffects(world, spell.onCast, new Ctx(world, action.player, undefined, action.target));
|
||||
break;
|
||||
}
|
||||
case "assemble":
|
||||
assembleFromPool(world, action.player, action.filter);
|
||||
break;
|
||||
case "buff":
|
||||
world.buff(action.id, action.attack, action.health);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret and perform an ability's effects in printed order. Each effect sees the
|
||||
* world as the effects before it left it. Depth-first: each action, and every
|
||||
* reaction it causes, finishes before the next.
|
||||
*/
|
||||
function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
|
||||
for (const effect of effects) {
|
||||
for (const action of actions(effect, ctx)) perform(world, action);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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][] = [];
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Queue is now locked: nothing that happens below adds to it.
|
||||
for (const [source, 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);
|
||||
for (let i = 0; i < times; i++) {
|
||||
world.record({ type: "Triggered", source, on: trigger.on });
|
||||
const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source);
|
||||
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may respond to `event`, in order of play: every minion in play and, for a
|
||||
* Death Event, the minion that died. It has left play, but its Deathrattle still answers.
|
||||
*/
|
||||
function listeners(world: World, event: Event): EntityId[] {
|
||||
const inPlay = world.minionsInPlay();
|
||||
if (event.type !== "Destroyed") return inPlay;
|
||||
return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
|
||||
}
|
||||
|
||||
/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */
|
||||
function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean {
|
||||
if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener;
|
||||
if (event.type === "Played") return on === "battlecry" && event.id === listener;
|
||||
if (event.type !== "Damaged") return false;
|
||||
const isMinion = !world.isHero(event.target);
|
||||
switch (on) {
|
||||
case "selfDamaged":
|
||||
return event.target === listener;
|
||||
case "friendlyMinionDamaged":
|
||||
return isMinion && world.ownerOf(event.target) === world.ownerOf(listener);
|
||||
case "anyMinionDamaged":
|
||||
return isMinion;
|
||||
case "heroDamaged":
|
||||
return !isMinion;
|
||||
case "deathrattle":
|
||||
case "battlecry":
|
||||
case "endOfTurn":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Entities, zones, order of play, stats and the event log: the game's state.
|
||||
// The rules for resolving actions live in `queue.ts`.
|
||||
|
||||
import type { Aura, Card, CardDef, CardLibrary } from "./card";
|
||||
import type { Event } from "./event";
|
||||
|
||||
/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */
|
||||
export type EntityId = number;
|
||||
export type PlayerId = 0 | 1;
|
||||
export type Zone = "deck" | "hand" | "board" | "graveyard";
|
||||
|
||||
/** Starting Health of each hero. */
|
||||
export const HERO_HEALTH = 30;
|
||||
|
||||
interface Entity {
|
||||
card: string;
|
||||
owner: PlayerId;
|
||||
zone: Zone;
|
||||
/** Stamped each time the entity enters the board; undefined until it first does. */
|
||||
orderOfPlay?: number;
|
||||
attack: number;
|
||||
/** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */
|
||||
health: number;
|
||||
/** Hit by a destroy effect: still in play until the Death Creation Step. */
|
||||
pendingDestroy: boolean;
|
||||
}
|
||||
|
||||
/** An aura in force: which player's minion has it. */
|
||||
export interface ActiveAura {
|
||||
owner: PlayerId;
|
||||
aura: Aura;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a player's choices: called with what is being chosen and the options, it
|
||||
* returns one of the options. Hearthstone would ask the player or pick at random.
|
||||
*/
|
||||
export type Chooser = (kind: string, options: readonly string[]) => string;
|
||||
|
||||
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
|
||||
type Side = Record<Zone, EntityId[]> & { hero: EntityId; armor: number };
|
||||
|
||||
export class World {
|
||||
/** Indexed by EntityId. */
|
||||
private readonly entities: Entity[] = [];
|
||||
private readonly sides: [Side, Side];
|
||||
private nextOrderOfPlay = 0;
|
||||
private events: Event[] = [];
|
||||
/** 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. */
|
||||
private chooser: Chooser = (_kind, options) => options[0];
|
||||
|
||||
/** A new game: both heroes are in play from the start, so they are the oldest entities. */
|
||||
constructor(private readonly library: CardLibrary) {
|
||||
const side = (owner: PlayerId): Side => {
|
||||
const hero = this.entities.length;
|
||||
this.entities.push({
|
||||
card: "Hero",
|
||||
owner,
|
||||
zone: "board",
|
||||
orderOfPlay: ++this.nextOrderOfPlay,
|
||||
attack: 0,
|
||||
health: HERO_HEALTH,
|
||||
pendingDestroy: false,
|
||||
});
|
||||
// In play, but not on the board list: the board holds minions only.
|
||||
return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 };
|
||||
};
|
||||
this.sides = [side(0), side(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board),
|
||||
* or at index `at` when given (clamped to the zone's length).
|
||||
*/
|
||||
create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId {
|
||||
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
||||
const { def } = this.library.get(card);
|
||||
const id = this.entities.length;
|
||||
this.entities.push({
|
||||
card: def.name,
|
||||
owner,
|
||||
zone,
|
||||
attack: def.attack ?? 0,
|
||||
health: def.health ?? 0,
|
||||
pendingDestroy: false,
|
||||
});
|
||||
this.enter(id, zone, at);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Move an entity to the end of `zone` on its owner's side, keeping its id. */
|
||||
moveTo(id: EntityId, zone: Zone): void {
|
||||
const { owner, zone: from } = this.entity(id);
|
||||
const list = this.sides[owner][from];
|
||||
list.splice(list.indexOf(id), 1);
|
||||
this.enter(id, zone);
|
||||
}
|
||||
|
||||
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
|
||||
private enter(id: EntityId, zone: Zone, at?: number): void {
|
||||
const entity = this.entity(id);
|
||||
const list = this.sides[entity.owner][zone];
|
||||
list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id);
|
||||
entity.zone = zone;
|
||||
if (zone === "board") {
|
||||
entity.orderOfPlay = ++this.nextOrderOfPlay;
|
||||
this.record({ type: "Summoned", id });
|
||||
}
|
||||
}
|
||||
|
||||
private entity(id: EntityId): Entity {
|
||||
const entity = this.entities[id];
|
||||
if (!entity) throw new Error(`unknown entity id ${id}`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
// ---- Queries ----
|
||||
|
||||
card(name: string): Card {
|
||||
return this.library.get(name);
|
||||
}
|
||||
|
||||
hasCard(name: string): boolean {
|
||||
return this.library.has(name);
|
||||
}
|
||||
|
||||
/** The cards an Assemble may offer, sorted by name. */
|
||||
pool(): Card[] {
|
||||
return this.library.pool();
|
||||
}
|
||||
|
||||
zoneOf(id: EntityId): Zone {
|
||||
return this.entity(id).zone;
|
||||
}
|
||||
|
||||
cardName(id: EntityId): string {
|
||||
return this.entity(id).card;
|
||||
}
|
||||
|
||||
ownerOf(id: EntityId): PlayerId {
|
||||
return this.entity(id).owner;
|
||||
}
|
||||
|
||||
/** When the entity last entered the board (larger is later); undefined if it never has. */
|
||||
orderOfPlay(id: EntityId): number | undefined {
|
||||
return this.entity(id).orderOfPlay;
|
||||
}
|
||||
|
||||
/** The ids in one player's zone, in zone order (left to right for the board). */
|
||||
zone(owner: PlayerId, zone: Zone): readonly EntityId[] {
|
||||
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
||||
return this.sides[owner][zone];
|
||||
}
|
||||
|
||||
hero(owner: PlayerId): EntityId {
|
||||
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
||||
return this.sides[owner].hero;
|
||||
}
|
||||
|
||||
isHero(id: EntityId): boolean {
|
||||
return this.sides.some((s) => s.hero === id);
|
||||
}
|
||||
|
||||
/** Every minion on either board, oldest first (order of play). */
|
||||
minionsInPlay(): EntityId[] {
|
||||
return [...this.sides[0].board, ...this.sides[1].board].sort(
|
||||
(a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!,
|
||||
);
|
||||
}
|
||||
|
||||
attack(id: EntityId): number {
|
||||
return this.entity(id).attack;
|
||||
}
|
||||
|
||||
health(id: EntityId): number {
|
||||
return this.entity(id).health;
|
||||
}
|
||||
|
||||
/** The cost printed on the entity's card. */
|
||||
cost(id: EntityId): number {
|
||||
return this.card(this.cardName(id)).def.cost;
|
||||
}
|
||||
|
||||
/** The minions in a player's hand, in hand order. */
|
||||
minionsInHand(owner: PlayerId): EntityId[] {
|
||||
return this.zone(owner, "hand").filter((id) => this.card(this.cardName(id)).def.type === "minion");
|
||||
}
|
||||
|
||||
/** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */
|
||||
isDoomed(id: EntityId): boolean {
|
||||
const entity = this.entity(id);
|
||||
return entity.health <= 0 || entity.pendingDestroy;
|
||||
}
|
||||
|
||||
/** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */
|
||||
boardIndex(id: EntityId): number | undefined {
|
||||
const index = this.sides[this.ownerOf(id)].board.indexOf(id);
|
||||
return index < 0 ? undefined : index;
|
||||
}
|
||||
|
||||
armor(owner: PlayerId): number {
|
||||
return this.sides[owner].armor;
|
||||
}
|
||||
|
||||
activeAuras(): readonly ActiveAura[] {
|
||||
return this.auras;
|
||||
}
|
||||
|
||||
log(): readonly Event[] {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
// ---- Changes: each one records its event ----
|
||||
|
||||
record(event: Event): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
/** Add a card to the library, or replace the card with the same name. */
|
||||
register(def: CardDef): void {
|
||||
this.library.register(def);
|
||||
}
|
||||
|
||||
/** Add a card made by Assemble (see `assemble.ts`). */
|
||||
registerAssembled(def: CardDef): void {
|
||||
this.library.registerAssembled(def);
|
||||
}
|
||||
|
||||
setChooser(chooser: Chooser): void {
|
||||
this.chooser = chooser;
|
||||
}
|
||||
|
||||
/** Ask the chooser to pick one of `options` (not empty). Throws if it answers something that isn't an option. */
|
||||
choose(kind: string, options: readonly string[]): string {
|
||||
const answer = this.chooser(kind, options);
|
||||
if (!options.includes(answer)) {
|
||||
throw new Error(`chooser answered "${answer}" for "${kind}"; the options were ${JSON.stringify(options)}`);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** Only an Aura Update calls this. */
|
||||
setActiveAuras(auras: ActiveAura[]): void {
|
||||
this.auras = auras;
|
||||
}
|
||||
|
||||
clearLog(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
damage(target: EntityId, amount: number): void {
|
||||
this.entity(target).health -= amount;
|
||||
this.record({ type: "Damaged", target, amount });
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the top card of `player`'s deck (the first in the list). An empty deck
|
||||
* records `Fatigue` and changes nothing else.
|
||||
*/
|
||||
draw(player: PlayerId): void {
|
||||
const id = this.zone(player, "deck")[0];
|
||||
if (id === undefined) {
|
||||
this.record({ type: "Fatigue", player });
|
||||
return;
|
||||
}
|
||||
this.moveTo(id, "hand");
|
||||
this.record({ type: "Drew", player, id });
|
||||
}
|
||||
|
||||
/** Mark for destruction. Nothing leaves play until the Death Creation Step. */
|
||||
markDestroyed(id: EntityId): void {
|
||||
this.entity(id).pendingDestroy = true;
|
||||
}
|
||||
|
||||
/** Move a minion from play to its owner's graveyard, recording its Death Event. */
|
||||
kill(id: EntityId, position: number): void {
|
||||
this.entity(id).pendingDestroy = false;
|
||||
this.moveTo(id, "graveyard");
|
||||
this.record({ type: "Destroyed", id, position });
|
||||
}
|
||||
|
||||
gainArmor(player: PlayerId, amount: number): void {
|
||||
this.sides[player].armor += amount;
|
||||
this.record({ type: "ArmorGained", player, amount });
|
||||
}
|
||||
|
||||
buff(id: EntityId, attack: number, health: number): void {
|
||||
const entity = this.entity(id);
|
||||
entity.attack += attack;
|
||||
entity.health += health;
|
||||
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
|
||||
}
|
||||
|
||||
gainAttack(id: EntityId, amount: number): void {
|
||||
const entity = this.entity(id);
|
||||
entity.attack += amount;
|
||||
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Test harness: the thin adapter the course tests call. It translates each
|
||||
// call into engine operations and holds no rules of its own.
|
||||
|
||||
import { cards } from "./content";
|
||||
import {
|
||||
assemble,
|
||||
CardLibrary,
|
||||
registerAssembled,
|
||||
runSequence,
|
||||
summon,
|
||||
triggerLabel,
|
||||
World,
|
||||
type CardDef,
|
||||
type Chooser,
|
||||
type Event,
|
||||
type Half,
|
||||
type Zone,
|
||||
} from "./engine";
|
||||
|
||||
export { AssembleError } from "./engine";
|
||||
export type { CardDef, Chooser, Half, Zone };
|
||||
|
||||
export type Id = number;
|
||||
export type Player = 0 | 1;
|
||||
|
||||
/** The events the tests read from `log()` (see course/harness.md). */
|
||||
export type GameEvent =
|
||||
| { type: "Summoned"; id: Id }
|
||||
| { type: "Damaged"; target: Id; amount: number }
|
||||
| { type: "Triggered"; source: Id; trigger: string }
|
||||
| { type: "Destroyed"; id: Id }
|
||||
| { type: "DeathPhase"; round: number }
|
||||
| { type: "Drew"; player: Player; id: Id }
|
||||
| { type: "Fatigue"; player: Player }
|
||||
| { type: "ArmorGained"; player: Player; amount: number }
|
||||
| { type: "StatsChanged"; id: Id; attack: number; health: number }
|
||||
| { type: "SpellCast"; card: string };
|
||||
|
||||
/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */
|
||||
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 };
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
if (event.type === "Played") return undefined;
|
||||
return { ...event };
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
summon(card: string, player?: Player): Id; // to right end of board
|
||||
addToHand(card: string, player?: Player): Id;
|
||||
addToDeck(card: string, player?: Player): Id;
|
||||
play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry)
|
||||
zoneOf(id: Id): Zone;
|
||||
board(player: Player): Id[]; // left to right
|
||||
hand(player: Player): Id[];
|
||||
orderOfPlay(id: Id): number;
|
||||
nameOf(id: Id): string;
|
||||
// Module 2
|
||||
hero(player: Player): Id;
|
||||
dealDamage(target: Id, amount: number): void; // one Sequence
|
||||
cast(card: string, player?: Player, target?: Id): void;
|
||||
health(id: Id): number;
|
||||
attack(id: Id): number;
|
||||
armor(player: Player): number;
|
||||
log(): GameEvent[];
|
||||
clearLog(): void;
|
||||
// Module 3
|
||||
destroy(target: Id): void; // marks pending destroy, one Sequence
|
||||
// Module 5
|
||||
registerCards(defs: CardDef[]): void; // add card definitions, replacing any with the same name
|
||||
assemble(base: string, donor: string, half: Half): string; // registers "<base> (Assembled)"; throws AssembleError
|
||||
cost(id: Id): number;
|
||||
setChooser(choose: Chooser): void; // makes Assemble's choices: kind "base" | "donor" | "half"
|
||||
}
|
||||
|
||||
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
|
||||
const world = new World(new CardLibrary(cards()));
|
||||
opts?.decks?.forEach((deck, player) => {
|
||||
for (const card of deck) world.create(card, player as Player, "deck");
|
||||
});
|
||||
|
||||
return {
|
||||
summon: (card, player = 0) => summon(world, card, player),
|
||||
addToHand: (card, player = 0) => world.create(card, player, "hand"),
|
||||
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
|
||||
// Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves.
|
||||
play: (handId) => runSequence(world, { type: "play", id: handId }),
|
||||
zoneOf: (id) => world.zoneOf(id),
|
||||
board: (player) => [...world.zone(player, "board")],
|
||||
hand: (player) => [...world.zone(player, "hand")],
|
||||
orderOfPlay: (id) => {
|
||||
const order = world.orderOfPlay(id);
|
||||
if (order === undefined) throw new Error(`entity ${id} has not entered play`);
|
||||
return order;
|
||||
},
|
||||
nameOf: (id) => world.cardName(id),
|
||||
|
||||
hero: (player) => world.hero(player),
|
||||
dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }),
|
||||
cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }),
|
||||
health: (id) => world.health(id),
|
||||
attack: (id) => world.attack(id),
|
||||
armor: (player) => world.armor(player),
|
||||
log: () => world.log().flatMap((event) => toGameEvent(event) ?? []),
|
||||
clearLog: () => world.clearLog(),
|
||||
|
||||
destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }),
|
||||
|
||||
registerCards: (defs) => defs.forEach((def) => world.register(def)),
|
||||
assemble: (base, donor, half) =>
|
||||
registerAssembled(world, assemble(world.card(base).def, world.card(donor).def, half)),
|
||||
cost: (id) => world.cost(id),
|
||||
setChooser: (choose) => world.setChooser(choose),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["tests/**/*.test.ts"], passWithNoTests: true },
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
# Module 5 scenarios: Behavior as data and Assemble
|
||||
|
||||
Each heading is a test name. The Rust test lives in `tests/rust/test_05.rs` and the TypeScript test in `tests/ts/test_05.test.ts`; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests belongs to player 0, and minions are summoned in the order listed. Tests that read the log call `clearLog()` right before the action under test.
|
||||
|
||||
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`.
|
||||
|
||||
Cards used (text from `course/catalog.json`; the clauses from `course/cards/`, written `[top | bottom]`):
|
||||
|
||||
- `Harvest Golem`, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." `[trigger deathrattle | effect summon "Damaged Golem" ×1]`, split 1.
|
||||
- `Dread Infernal`, 6-mana 6/6: "Battlecry: Deal 1 damage to ALL other characters." `[trigger battlecry | effect dealDamage allOtherCharacters 1]`, split 1.
|
||||
- `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`.
|
||||
- `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`.
|
||||
- `Test Workshop` (fictional spell), 2 mana: "Assemble a Battlecry minion. Give all minions in your hand +1/+1." `[effect assemble {filter: {trigger: battlecry}}, effect buffHand +1/+1]`.
|
||||
|
||||
The harness adds `registerCards(defs)`, `assemble(base, donor, half)`, `cost(id)` and `setChooser(choose)`. A chooser is called as `choose(kind, options)` and returns one of `options`. For an `assemble` effect it is called three times, in this order: `kind` `"base"` with the names of the minions the base may be (sorted); `"donor"` with the names of the minions that can change the base's text (sorted); `"half"` with the halves (`"top"`, `"bottom"`, in that order) whose replacement changes the base's text. Random choice is the chooser's job, so tests inject one.
|
||||
|
||||
### test_05_regression_data_cards
|
||||
|
||||
- Setup: read every file in `cards/` and parse it as a card definition. `newGame()` (no decks).
|
||||
- Action: `registerCards` with all of them.
|
||||
- Assertions:
|
||||
- The files include every card the Module 1–4 tests use: `Armorsmith`, `Acolyte of Pain`, `Loot Hoarder`, `Harvest Golem`, `Damaged Golem`, `Unstable Ghoul`, `Baron Rivendare`, `Novice Engineer`, `Whirlwind`, `Twisting Nether`, `Test Echo`, `Test Reactor`, `Test Filler`.
|
||||
- For each minion file, in file-name order: `summon(name, 0)` returns an id whose `nameOf` is the file's `name` and whose `attack`, `health` and `cost` equal the file's `attack`, `health` and `cost`.
|
||||
- The Module 1–4 tests, which run in the same workspace, are the rest of this regression: they now pass against cards loaded from data.
|
||||
|
||||
### test_05_assemble_bottom_half
|
||||
|
||||
- Setup: `assemble("Harvest Golem", "Dread Infernal", "bottom")` returns `"Harvest Golem (Assembled)"`: `[trigger deathrattle | effect dealDamage allOtherCharacters 1]`, "Deathrattle: Deal 1 damage to ALL other characters." Summon `Baron Rivendare` (id B), `Test Filler` (id F), `Test Sponge` (id S), then `"Harvest Golem (Assembled)"` (id A). Call the heroes H0 = `hero(0)` and H1 = `hero(1)`.
|
||||
- Action: `destroy(A)`.
|
||||
- Assertions:
|
||||
- The log is exactly, in this order: `Destroyed{id: A}`, `DeathPhase{round: 1}`, `Triggered{source: A, trigger: "deathrattle"}`, `Damaged{target: H0, amount: 1}`, `Damaged{target: H1, amount: 1}`, `Damaged{target: B, amount: 1}`, `Damaged{target: F, amount: 1}`, `Damaged{target: S, amount: 1}`, then the same `Triggered` and five `Damaged` events again, then `Destroyed{id: F}`, `DeathPhase{round: 2}`. The new Deathrattle is a Deathrattle like any other, so Baron doubles it; F, at 0 Health after the first activation, is still in play for the second.
|
||||
- `health(H0)` and `health(H1)` are 28, `health(B)` is 5, `health(S)` is 1.
|
||||
- `zoneOf(F)` is graveyard and `board(0)` is `[B, S]`. No Damaged Golem: the bottom half that summoned it was replaced.
|
||||
|
||||
### test_05_assembled_keeps_base_stats
|
||||
|
||||
- Setup: `assemble("Harvest Golem", "Dread Infernal", "bottom")` (name N).
|
||||
- Action: `addToHand(N, 0)` (id A).
|
||||
- Assertions:
|
||||
- `nameOf(A)` is N, and N contains `"Harvest Golem"`.
|
||||
- `cost(A)` is 3, `attack(A)` is 2, `health(A)` is 3: Harvest Golem's, not Dread Infernal's 6, 6, 6.
|
||||
|
||||
### test_05_assemble_must_change_text
|
||||
|
||||
- Action: `assemble("Loot Hoarder", "Loot Hoarder", "bottom")`, then `assemble("Loot Hoarder", "Loot Hoarder", "top")`.
|
||||
- Assertions: each fails with `AssembleError` (TypeScript: throws an `AssembleError`; Rust: returns `Err`). A minion assembled from itself has the same text.
|
||||
|
||||
### test_05_top_half_swap
|
||||
|
||||
- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. `assemble("Loot Hoarder", "Novice Engineer", "top")` (name N): Loot Hoarder's top half `[trigger deathrattle]` is replaced by Novice Engineer's `[trigger battlecry]`, so N is `[trigger battlecry | effect draw 1]`, "Battlecry: Draw a card." `addToHand(N, 0)` (id E).
|
||||
- Action 1: `clearLog()`, then `play(E)`.
|
||||
- Assertions 1:
|
||||
- `hand(0)` has exactly 1 card; call it C.
|
||||
- The log is exactly: `Summoned{id: E}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C}`.
|
||||
- `attack(E)` is 2 and `health(E)` is 1: Loot Hoarder's stats.
|
||||
- Action 2: `clearLog()`, then `destroy(E)`.
|
||||
- Assertions 2:
|
||||
- The log is exactly: `Destroyed{id: E}`, `DeathPhase{round: 1}`. It has no Deathrattle any more.
|
||||
- `hand(0)` still has exactly 1 card.
|
||||
|
||||
### test_05_assemble_effect_with_chooser
|
||||
|
||||
- Setup: `newGame()`. `addToHand("Test Filler", 0)` (id T). Set a chooser that records every call and answers `"Novice Engineer"` for `"base"`, `"Harvest Golem"` for `"donor"` and `"bottom"` for `"half"`. Call `clearLog()`.
|
||||
- Action: player 0 casts `Test Workshop`.
|
||||
- Assertions:
|
||||
- The chooser was called exactly three times, with kinds `"base"`, `"donor"`, `"half"` in that order.
|
||||
- The `"base"` options are exactly the names of the minion files in `cards/` that have a `battlecry` trigger clause, sorted. (With the course's cards: `Dread Infernal`, `Novice Engineer`.)
|
||||
- The `"donor"` options include `"Harvest Golem"` and do not include `"Novice Engineer"` (the base itself).
|
||||
- The `"half"` options are `["top", "bottom"]`: Harvest Golem's top and bottom halves both differ from Novice Engineer's.
|
||||
- `hand(0)` is `[T, A]` for some new id A. `nameOf(A)` is `"Novice Engineer (Assembled)"`, `cost(A)` is 2, `attack(A)` is 2 and `health(A)` is 2 (1/1, then +1/+1).
|
||||
- The log is exactly: `SpellCast{card: "Test Workshop"}`, `StatsChanged{id: T, attack: 2, health: 2}`, `StatsChanged{id: A, attack: 2, health: 2}`. Getting the Assembled minion records no event; then every minion in hand gets +1/+1, in hand order.
|
||||
- Action 2: `clearLog()`, then `play(A)`.
|
||||
- Assertions 2: `board(0)` has 2 minions, A first, then a new id D with `nameOf(D)` `"Damaged Golem"`. The log is exactly: `Summoned{id: A}`, `Triggered{source: A, trigger: "battlecry"}`, `Summoned{id: D}`. "Battlecry: Summon a 2/1 Damaged Golem." In the course, a summon from a minion still in play goes to its right; a Deathrattle, whose minion has left play, summons where it stood (Module 3).
|
||||
|
||||
### test_05_additive_multipliers_stack
|
||||
|
||||
- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Test Chorus`, `Test Chorus`, then `Loot Hoarder` (id H). Call `clearLog()`.
|
||||
- Action: `dealDamage(H, 1)`.
|
||||
- Assertions:
|
||||
- `hand(0)` has 3 cards.
|
||||
- The log contains exactly three `Triggered{source: H, trigger: "deathrattle"}`. Each `additive` multiplier with `times` 2 adds one activation, so two of them give 1 + 1 + 1. (Two Baron Rivendares, stacking `max`, give 2: see Module 4.) A new card with a new stacking behaviour, and no new code.
|
||||
@@ -0,0 +1,308 @@
|
||||
// Module 5: Behavior as data and Assemble. Each test matches a heading in scenarios.md.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use engine::harness::*;
|
||||
|
||||
/// Every file in the workspace's `cards/` directory, in file-name order: (raw JSON, parsed).
|
||||
fn card_files() -> Vec<(serde_json::Value, CardDef)> {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/cards");
|
||||
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap_or_else(|e| panic!("cannot read {dir}: {e}"))
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let text = std::fs::read_to_string(p).unwrap();
|
||||
let raw: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let def: CardDef =
|
||||
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", p.display()));
|
||||
(raw, def)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Names of the minion files with a trigger clause listening for `on`, sorted.
|
||||
fn minions_with_trigger(on: &str) -> Vec<String> {
|
||||
let mut names: Vec<String> = card_files()
|
||||
.into_iter()
|
||||
.filter(|(raw, _)| raw["type"] == "minion")
|
||||
.filter(|(raw, _)| {
|
||||
raw["text"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "trigger" && c["on"] == on)
|
||||
})
|
||||
.map(|(raw, _)| raw["name"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn fillers(n: usize) -> Vec<String> {
|
||||
vec!["Test Filler".to_string(); n]
|
||||
}
|
||||
|
||||
fn damaged(target: Id, amount: i32) -> GameEvent {
|
||||
GameEvent::Damaged { target, amount }
|
||||
}
|
||||
|
||||
fn triggered(source: Id, trigger: &str) -> GameEvent {
|
||||
GameEvent::Triggered {
|
||||
source,
|
||||
trigger: trigger.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_regression_data_cards() {
|
||||
let files = card_files();
|
||||
let names: Vec<&str> = files
|
||||
.iter()
|
||||
.map(|(raw, _)| raw["name"].as_str().unwrap())
|
||||
.collect();
|
||||
for used in [
|
||||
"Armorsmith",
|
||||
"Acolyte of Pain",
|
||||
"Loot Hoarder",
|
||||
"Harvest Golem",
|
||||
"Damaged Golem",
|
||||
"Unstable Ghoul",
|
||||
"Baron Rivendare",
|
||||
"Novice Engineer",
|
||||
"Whirlwind",
|
||||
"Twisting Nether",
|
||||
"Test Echo",
|
||||
"Test Reactor",
|
||||
"Test Filler",
|
||||
] {
|
||||
assert!(names.contains(&used), "cards/ has no file for {used}");
|
||||
}
|
||||
|
||||
let mut g = new_game(GameOpts::default());
|
||||
g.register_cards(files.iter().map(|(_, def)| def.clone()).collect());
|
||||
|
||||
for (raw, _) in files.iter().filter(|(raw, _)| raw["type"] == "minion") {
|
||||
let name = raw["name"].as_str().unwrap();
|
||||
let id = g.summon(name, 0);
|
||||
assert_eq!(g.name_of(id), name);
|
||||
assert_eq!(
|
||||
g.attack(id) as i64,
|
||||
raw["attack"].as_i64().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
g.health(id) as i64,
|
||||
raw["health"].as_i64().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(g.cost(id) as i64, raw["cost"].as_i64().unwrap(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_assemble_bottom_half() {
|
||||
let mut g = new_game(GameOpts::default());
|
||||
let name = g
|
||||
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
||||
.unwrap();
|
||||
assert_eq!(name, "Harvest Golem (Assembled)");
|
||||
let b = g.summon("Baron Rivendare", 0);
|
||||
let f = g.summon("Test Filler", 0);
|
||||
let s = g.summon("Test Sponge", 0);
|
||||
let a = g.summon(&name, 0);
|
||||
let (h0, h1) = (g.hero(0), g.hero(1));
|
||||
g.clear_log();
|
||||
|
||||
g.destroy(a);
|
||||
|
||||
let activation = vec![
|
||||
triggered(a, "deathrattle"),
|
||||
damaged(h0, 1),
|
||||
damaged(h1, 1),
|
||||
damaged(b, 1),
|
||||
damaged(f, 1),
|
||||
damaged(s, 1),
|
||||
];
|
||||
let mut expected = vec![
|
||||
GameEvent::Destroyed { id: a },
|
||||
GameEvent::DeathPhase { round: 1 },
|
||||
];
|
||||
expected.extend(activation.clone());
|
||||
expected.extend(activation);
|
||||
expected.push(GameEvent::Destroyed { id: f });
|
||||
expected.push(GameEvent::DeathPhase { round: 2 });
|
||||
assert_eq!(g.log(), expected);
|
||||
assert_eq!(g.health(h0), 28);
|
||||
assert_eq!(g.health(h1), 28);
|
||||
assert_eq!(g.health(b), 5);
|
||||
assert_eq!(g.health(s), 1);
|
||||
assert_eq!(g.zone_of(f), Zone::Graveyard);
|
||||
assert_eq!(g.board(0), vec![b, s]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_assembled_keeps_base_stats() {
|
||||
let mut g = new_game(GameOpts::default());
|
||||
let name = g
|
||||
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
||||
.unwrap();
|
||||
|
||||
let a = g.add_to_hand(&name, 0);
|
||||
|
||||
assert_eq!(g.name_of(a), name);
|
||||
assert!(name.contains("Harvest Golem"));
|
||||
assert_eq!(g.cost(a), 3);
|
||||
assert_eq!(g.attack(a), 2);
|
||||
assert_eq!(g.health(a), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_assemble_must_change_text() {
|
||||
let mut g = new_game(GameOpts::default());
|
||||
|
||||
let bottom = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Bottom);
|
||||
let top = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Top);
|
||||
|
||||
assert!(bottom.is_err(), "{bottom:?}");
|
||||
assert!(top.is_err(), "{top:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_top_half_swap() {
|
||||
let mut g = new_game(GameOpts {
|
||||
decks: Some([fillers(5), vec![]]),
|
||||
});
|
||||
let name = g
|
||||
.assemble("Loot Hoarder", "Novice Engineer", Half::Top)
|
||||
.unwrap();
|
||||
let e = g.add_to_hand(&name, 0);
|
||||
g.clear_log();
|
||||
|
||||
g.play(e);
|
||||
|
||||
let hand = g.hand(0);
|
||||
assert_eq!(hand.len(), 1);
|
||||
let c = hand[0];
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Summoned { id: e },
|
||||
triggered(e, "battlecry"),
|
||||
GameEvent::Drew { player: 0, id: c },
|
||||
]
|
||||
);
|
||||
assert_eq!(g.attack(e), 2);
|
||||
assert_eq!(g.health(e), 1);
|
||||
|
||||
g.clear_log();
|
||||
g.destroy(e);
|
||||
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Destroyed { id: e },
|
||||
GameEvent::DeathPhase { round: 1 },
|
||||
]
|
||||
);
|
||||
assert_eq!(g.hand(0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_assemble_effect_with_chooser() {
|
||||
let mut g = new_game(GameOpts::default());
|
||||
let t = g.add_to_hand("Test Filler", 0);
|
||||
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" => "Harvest Golem",
|
||||
_ => "bottom",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
g.clear_log();
|
||||
|
||||
g.cast("Test Workshop", 0, None);
|
||||
|
||||
let calls = calls.borrow().clone();
|
||||
let kinds: Vec<&str> = calls.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(kinds, vec!["base", "donor", "half"]);
|
||||
assert_eq!(calls[0].1, minions_with_trigger("battlecry"));
|
||||
assert!(calls[1].1.contains(&"Harvest Golem".to_string()));
|
||||
assert!(!calls[1].1.contains(&"Novice Engineer".to_string()));
|
||||
assert_eq!(calls[2].1, vec!["top".to_string(), "bottom".to_string()]);
|
||||
|
||||
let hand = g.hand(0);
|
||||
assert_eq!(hand.len(), 2);
|
||||
assert_eq!(hand[0], t);
|
||||
let a = hand[1];
|
||||
assert_eq!(g.name_of(a), "Novice Engineer (Assembled)");
|
||||
assert_eq!(g.cost(a), 2);
|
||||
assert_eq!(g.attack(a), 2);
|
||||
assert_eq!(g.health(a), 2);
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::SpellCast {
|
||||
card: "Test Workshop".to_string()
|
||||
},
|
||||
GameEvent::StatsChanged {
|
||||
id: t,
|
||||
attack: 2,
|
||||
health: 2
|
||||
},
|
||||
GameEvent::StatsChanged {
|
||||
id: a,
|
||||
attack: 2,
|
||||
health: 2
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
g.clear_log();
|
||||
g.play(a);
|
||||
|
||||
let board = g.board(0);
|
||||
assert_eq!(board.len(), 2);
|
||||
assert_eq!(board[0], a);
|
||||
let d = board[1];
|
||||
assert_eq!(g.name_of(d), "Damaged Golem");
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Summoned { id: a },
|
||||
triggered(a, "battlecry"),
|
||||
GameEvent::Summoned { id: d },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_05_additive_multipliers_stack() {
|
||||
let mut g = new_game(GameOpts {
|
||||
decks: Some([fillers(5), vec![]]),
|
||||
});
|
||||
g.summon("Test Chorus", 0);
|
||||
g.summon("Test Chorus", 0);
|
||||
let h = g.summon("Loot Hoarder", 0);
|
||||
g.clear_log();
|
||||
|
||||
g.deal_damage(h, 1);
|
||||
|
||||
assert_eq!(g.hand(0).len(), 3);
|
||||
let activations = g
|
||||
.log()
|
||||
.into_iter()
|
||||
.filter(|e| *e == triggered(h, "deathrattle"))
|
||||
.count();
|
||||
assert_eq!(activations, 3);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/// <reference types="vite/client" />
|
||||
// Module 5: Behavior as data and Assemble. Each test matches a heading in scenarios.md.
|
||||
import { expect, test } from "vitest";
|
||||
import { AssembleError, newGame, type CardDef } from "../src/harness";
|
||||
|
||||
/** Every file in the workspace's `cards/` directory, keyed by path (sorted by file name below). */
|
||||
const files = import.meta.glob<CardDef>("../cards/*.json", { eager: true, import: "default" });
|
||||
const cardFiles = (): CardDef[] =>
|
||||
Object.keys(files)
|
||||
.sort()
|
||||
.map((path) => files[path]);
|
||||
|
||||
/** Names of the minion files with a trigger clause listening for `on`, sorted. */
|
||||
const minionsWithTrigger = (on: string): string[] =>
|
||||
cardFiles()
|
||||
.filter((c) => c.type === "minion" && c.text.some((clause) => clause.kind === "trigger" && clause.on === on))
|
||||
.map((c) => c.name)
|
||||
.sort();
|
||||
|
||||
const fillers = (n: number): string[] => Array(n).fill("Test Filler");
|
||||
|
||||
test("test_05_regression_data_cards", () => {
|
||||
const defs = cardFiles();
|
||||
const names = defs.map((c) => c.name);
|
||||
for (const used of [
|
||||
"Armorsmith",
|
||||
"Acolyte of Pain",
|
||||
"Loot Hoarder",
|
||||
"Harvest Golem",
|
||||
"Damaged Golem",
|
||||
"Unstable Ghoul",
|
||||
"Baron Rivendare",
|
||||
"Novice Engineer",
|
||||
"Whirlwind",
|
||||
"Twisting Nether",
|
||||
"Test Echo",
|
||||
"Test Reactor",
|
||||
"Test Filler",
|
||||
]) {
|
||||
expect(names, `cards/ has no file for ${used}`).toContain(used);
|
||||
}
|
||||
|
||||
const g = newGame();
|
||||
g.registerCards(defs);
|
||||
|
||||
for (const def of defs.filter((c) => c.type === "minion")) {
|
||||
const id = g.summon(def.name, 0);
|
||||
expect(g.nameOf(id)).toBe(def.name);
|
||||
expect(g.attack(id), def.name).toBe(def.attack);
|
||||
expect(g.health(id), def.name).toBe(def.health);
|
||||
expect(g.cost(id), def.name).toBe(def.cost);
|
||||
}
|
||||
});
|
||||
|
||||
test("test_05_assemble_bottom_half", () => {
|
||||
const g = newGame();
|
||||
const name = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
|
||||
expect(name).toBe("Harvest Golem (Assembled)");
|
||||
const b = g.summon("Baron Rivendare", 0);
|
||||
const f = g.summon("Test Filler", 0);
|
||||
const s = g.summon("Test Sponge", 0);
|
||||
const a = g.summon(name, 0);
|
||||
const [h0, h1] = [g.hero(0), g.hero(1)];
|
||||
g.clearLog();
|
||||
|
||||
g.destroy(a);
|
||||
|
||||
const activation = [
|
||||
{ type: "Triggered", source: a, trigger: "deathrattle" },
|
||||
{ type: "Damaged", target: h0, amount: 1 },
|
||||
{ type: "Damaged", target: h1, amount: 1 },
|
||||
{ type: "Damaged", target: b, amount: 1 },
|
||||
{ type: "Damaged", target: f, amount: 1 },
|
||||
{ type: "Damaged", target: s, amount: 1 },
|
||||
];
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Destroyed", id: a },
|
||||
{ type: "DeathPhase", round: 1 },
|
||||
...activation,
|
||||
...activation,
|
||||
{ type: "Destroyed", id: f },
|
||||
{ type: "DeathPhase", round: 2 },
|
||||
]);
|
||||
expect(g.health(h0)).toBe(28);
|
||||
expect(g.health(h1)).toBe(28);
|
||||
expect(g.health(b)).toBe(5);
|
||||
expect(g.health(s)).toBe(1);
|
||||
expect(g.zoneOf(f)).toBe("graveyard");
|
||||
expect(g.board(0)).toEqual([b, s]);
|
||||
});
|
||||
|
||||
test("test_05_assembled_keeps_base_stats", () => {
|
||||
const g = newGame();
|
||||
const name = g.assemble("Harvest Golem", "Dread Infernal", "bottom");
|
||||
|
||||
const a = g.addToHand(name, 0);
|
||||
|
||||
expect(g.nameOf(a)).toBe(name);
|
||||
expect(name).toContain("Harvest Golem");
|
||||
expect(g.cost(a)).toBe(3);
|
||||
expect(g.attack(a)).toBe(2);
|
||||
expect(g.health(a)).toBe(3);
|
||||
});
|
||||
|
||||
test("test_05_assemble_must_change_text", () => {
|
||||
const g = newGame();
|
||||
|
||||
expect(() => g.assemble("Loot Hoarder", "Loot Hoarder", "bottom")).toThrow(AssembleError);
|
||||
expect(() => g.assemble("Loot Hoarder", "Loot Hoarder", "top")).toThrow(AssembleError);
|
||||
});
|
||||
|
||||
test("test_05_top_half_swap", () => {
|
||||
const g = newGame({ decks: [fillers(5), []] });
|
||||
const name = g.assemble("Loot Hoarder", "Novice Engineer", "top");
|
||||
const e = g.addToHand(name, 0);
|
||||
g.clearLog();
|
||||
|
||||
g.play(e);
|
||||
|
||||
const hand = g.hand(0);
|
||||
expect(hand).toHaveLength(1);
|
||||
const [c] = hand;
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Summoned", id: e },
|
||||
{ type: "Triggered", source: e, trigger: "battlecry" },
|
||||
{ type: "Drew", player: 0, id: c },
|
||||
]);
|
||||
expect(g.attack(e)).toBe(2);
|
||||
expect(g.health(e)).toBe(1);
|
||||
|
||||
g.clearLog();
|
||||
g.destroy(e);
|
||||
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Destroyed", id: e },
|
||||
{ type: "DeathPhase", round: 1 },
|
||||
]);
|
||||
expect(g.hand(0)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("test_05_assemble_effect_with_chooser", () => {
|
||||
const g = newGame();
|
||||
const t = g.addToHand("Test Filler", 0);
|
||||
const calls: [string, string[]][] = [];
|
||||
g.setChooser((kind, options) => {
|
||||
calls.push([kind, [...options]]);
|
||||
return kind === "base" ? "Novice Engineer" : kind === "donor" ? "Harvest Golem" : "bottom";
|
||||
});
|
||||
g.clearLog();
|
||||
|
||||
g.cast("Test Workshop", 0);
|
||||
|
||||
expect(calls.map(([kind]) => kind)).toEqual(["base", "donor", "half"]);
|
||||
expect(calls[0][1]).toEqual(minionsWithTrigger("battlecry"));
|
||||
expect(calls[1][1]).toContain("Harvest Golem");
|
||||
expect(calls[1][1]).not.toContain("Novice Engineer");
|
||||
expect(calls[2][1]).toEqual(["top", "bottom"]);
|
||||
|
||||
const hand = g.hand(0);
|
||||
expect(hand).toHaveLength(2);
|
||||
expect(hand[0]).toBe(t);
|
||||
const a = hand[1];
|
||||
expect(g.nameOf(a)).toBe("Novice Engineer (Assembled)");
|
||||
expect(g.cost(a)).toBe(2);
|
||||
expect(g.attack(a)).toBe(2);
|
||||
expect(g.health(a)).toBe(2);
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "SpellCast", card: "Test Workshop" },
|
||||
{ type: "StatsChanged", id: t, attack: 2, health: 2 },
|
||||
{ type: "StatsChanged", id: a, attack: 2, health: 2 },
|
||||
]);
|
||||
|
||||
g.clearLog();
|
||||
g.play(a);
|
||||
|
||||
const board = g.board(0);
|
||||
expect(board).toHaveLength(2);
|
||||
expect(board[0]).toBe(a);
|
||||
const d = board[1];
|
||||
expect(g.nameOf(d)).toBe("Damaged Golem");
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Summoned", id: a },
|
||||
{ type: "Triggered", source: a, trigger: "battlecry" },
|
||||
{ type: "Summoned", id: d },
|
||||
]);
|
||||
});
|
||||
|
||||
test("test_05_additive_multipliers_stack", () => {
|
||||
const g = newGame({ decks: [fillers(5), []] });
|
||||
g.summon("Test Chorus", 0);
|
||||
g.summon("Test Chorus", 0);
|
||||
const h = g.summon("Loot Hoarder", 0);
|
||||
g.clearLog();
|
||||
|
||||
g.dealDamage(h, 1);
|
||||
|
||||
expect(g.hand(0)).toHaveLength(3);
|
||||
const activations = g
|
||||
.log()
|
||||
.filter((e) => e.type === "Triggered" && e.source === h && e.trigger === "deathrattle");
|
||||
expect(activations).toHaveLength(3);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Acolyte of Pain",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 1,
|
||||
"health": 4,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "selfDamaged"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "draw",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Armorsmith",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 1,
|
||||
"health": 4,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "friendlyMinionDamaged"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "gainArmor",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Baron Rivendare",
|
||||
"type": "minion",
|
||||
"cost": 4,
|
||||
"attack": 1,
|
||||
"health": 7,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "aura",
|
||||
"modifier": {
|
||||
"type": "triggerMultiplier",
|
||||
"trigger": "deathrattle",
|
||||
"times": 2,
|
||||
"stacking": "max"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Brann Bronzebeard",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 2,
|
||||
"health": 4,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "aura",
|
||||
"modifier": {
|
||||
"type": "triggerMultiplier",
|
||||
"trigger": "battlecry",
|
||||
"times": 2,
|
||||
"stacking": "max"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Damaged Golem",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 2,
|
||||
"health": 1,
|
||||
"split": 0,
|
||||
"text": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Drakkari Enchanter",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 1,
|
||||
"health": 5,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "aura",
|
||||
"modifier": {
|
||||
"type": "triggerMultiplier",
|
||||
"trigger": "endOfTurn",
|
||||
"times": 2,
|
||||
"stacking": "max"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Dread Infernal",
|
||||
"type": "minion",
|
||||
"cost": 6,
|
||||
"attack": 6,
|
||||
"health": 6,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "battlecry"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "dealDamage",
|
||||
"target": "allOtherCharacters",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Frothing Berserker",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 2,
|
||||
"health": 4,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "anyMinionDamaged"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "gainAttack",
|
||||
"target": "self",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Harvest Golem",
|
||||
"type": "minion",
|
||||
"cost": 3,
|
||||
"attack": 2,
|
||||
"health": 3,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "deathrattle"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "summon",
|
||||
"card": "Damaged Golem",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Loot Hoarder",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 2,
|
||||
"health": 1,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "deathrattle"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "draw",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Novice Engineer",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "battlecry"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "draw",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Test Chorus",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 5,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "aura",
|
||||
"modifier": {
|
||||
"type": "triggerMultiplier",
|
||||
"trigger": "deathrattle",
|
||||
"times": 2,
|
||||
"stacking": "additive"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Test Echo",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 5,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "anyMinionDamaged"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "dealDamage",
|
||||
"target": "enemyHero",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Test Filler",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"split": 0,
|
||||
"text": [],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Test Reactor",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 5,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "heroDamaged"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "gainAttack",
|
||||
"target": "self",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Test Sponge",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 3,
|
||||
"split": 0,
|
||||
"text": [],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Test Workshop",
|
||||
"type": "spell",
|
||||
"cost": 2,
|
||||
"split": 1,
|
||||
"text": [
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "assemble",
|
||||
"filter": {
|
||||
"trigger": "battlecry"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "buffHand",
|
||||
"attack": 1,
|
||||
"health": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"fictional": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Twisting Nether",
|
||||
"type": "spell",
|
||||
"cost": 8,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "destroy",
|
||||
"target": "allMinions"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Unstable Ghoul",
|
||||
"type": "minion",
|
||||
"cost": 2,
|
||||
"attack": 1,
|
||||
"health": 3,
|
||||
"split": 2,
|
||||
"text": [
|
||||
{
|
||||
"kind": "keyword",
|
||||
"keyword": "taunt"
|
||||
},
|
||||
{
|
||||
"kind": "trigger",
|
||||
"on": "deathrattle"
|
||||
},
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "dealDamage",
|
||||
"target": "allMinions",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Whirlwind",
|
||||
"type": "spell",
|
||||
"cost": 1,
|
||||
"split": 0,
|
||||
"text": [
|
||||
{
|
||||
"kind": "effect",
|
||||
"effect": {
|
||||
"type": "dealDamage",
|
||||
"target": "allMinions",
|
||||
"amount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -174,6 +174,34 @@
|
||||
"text": "",
|
||||
"fictional": true
|
||||
},
|
||||
{
|
||||
"name": "Test Sponge",
|
||||
"kind": "card",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 3,
|
||||
"text": "",
|
||||
"fictional": true
|
||||
},
|
||||
{
|
||||
"name": "Test Chorus",
|
||||
"kind": "card",
|
||||
"type": "minion",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 5,
|
||||
"text": "Your minions trigger their Deathrattles an additional time.",
|
||||
"fictional": true
|
||||
},
|
||||
{
|
||||
"name": "Test Workshop",
|
||||
"kind": "card",
|
||||
"type": "spell",
|
||||
"cost": 2,
|
||||
"text": "Assemble a Battlecry minion. Give all minions in your hand +1/+1.",
|
||||
"fictional": true
|
||||
},
|
||||
{
|
||||
"name": "BABA",
|
||||
"kind": "word",
|
||||
|
||||
+6
-2
@@ -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()`.
|
||||
- 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` and `Twisting Nether` (the M3 Expansion Drop card).
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
### Side by side (Part I)
|
||||
|
||||
@@ -35,7 +35,11 @@ Each module adds functions; `start-module` prints the line for the module you st
|
||||
| M3 | `destroy(target)` | `destroy(&mut self, target: Id)` |
|
||||
| M4 | `play(handId)` now required (one Sequence; Battlecry) | `play(&mut self, hand_id: Id)` |
|
||||
| M5 | `registerCards(defs)` | `register_cards(&mut self, defs: Vec<CardDef>)` |
|
||||
| M5 | `assemble(base, donor, half)` (throws `AssembleError`) | `assemble(...) -> Result<String, AssembleError>` |
|
||||
| M5 | `assemble(base, donor, half)` (throws `AssembleError`) | `assemble(&mut self, base: &str, donor: &str, half: Half) -> Result<String, AssembleError>` (`Half::Top` / `Half::Bottom`) |
|
||||
| M5 | `cost(id)` | `cost(&self, id: Id) -> i32` |
|
||||
| M5 | `setChooser(choose)` | `set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)` |
|
||||
|
||||
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`.
|
||||
|
||||
### `GameEvent`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Structured card text. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. split divides top (text[0:split]) from bottom (text[split:]).",
|
||||
"$comment": "Structured card text. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. split divides top (text[0:split]) from bottom (text[split:]). Effect assemble: put a minion into your hand, chosen from the pool of minions matching filter (filter.trigger: it has a trigger clause with that on), with half its text replaced (Module 5). Effect buffHand: give every minion in your hand +attack/+health.",
|
||||
"type": "object",
|
||||
"required": ["name", "type", "cost", "text", "split"],
|
||||
"properties": {
|
||||
@@ -36,7 +36,10 @@
|
||||
{ "type": "object", "required": ["type", "amount"], "properties": { "type": { "const": "gainArmor" }, "amount": { "type": "integer", "minimum": 1 } } },
|
||||
{ "type": "object", "required": ["type", "target", "amount"], "properties": { "type": { "const": "gainAttack" }, "target": { "enum": ["self", "allMinions", "allOtherMinions", "allOtherCharacters", "enemyHero", "chosen"] }, "amount": { "type": "integer" } } },
|
||||
{ "type": "object", "required": ["type", "target"], "properties": { "type": { "const": "destroy" }, "target": { "enum": ["self", "allMinions", "allOtherMinions", "allOtherCharacters", "enemyHero", "chosen"] } } },
|
||||
{ "type": "object", "required": ["type", "word", "at"], "properties": { "type": { "const": "spawnText" }, "word": { "type": "string" }, "at": { "const": "self" } } }
|
||||
{ "type": "object", "required": ["type", "word", "at"], "properties": { "type": { "const": "spawnText" }, "word": { "type": "string" }, "at": { "const": "self" } } },
|
||||
{ "type": "object", "required": ["type", "filter"], "properties": { "type": { "const": "assemble" }, "filter": { "type": "object", "properties": {
|
||||
"trigger": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] } } } } },
|
||||
{ "type": "object", "required": ["type", "attack", "health"], "properties": { "type": { "const": "buffHand" }, "attack": { "type": "integer", "minimum": 0 }, "health": { "type": "integer", "minimum": 0 } } }
|
||||
] } } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ mkdirSync(join(ws, "tests"), { recursive: true });
|
||||
for (const f of files) cpSync(f, join(ws, "tests", basename(f)));
|
||||
console.log(`Copied ${files.length} test file(s) for module ${num} into ${join(ws, "tests")}`);
|
||||
|
||||
// The course's card data (read by your content from Module 5). Refreshed on every start;
|
||||
// card files you add yourself are kept.
|
||||
const cards = join(courseRoot(), "cards");
|
||||
if (existsSync(cards)) {
|
||||
cpSync(cards, join(ws, "cards"), { recursive: true });
|
||||
console.log(`Copied the course card data into ${join(ws, "cards")}`);
|
||||
}
|
||||
|
||||
// Harness additions: module-local note, else the "- M<N>:" line of course/harness.md.
|
||||
const note = join(mod.path, "harness-adds.md");
|
||||
let adds = existsSync(note) ? readFileSync(note, "utf8").trim() : "";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Test Fixture","type":"minion","cost":1,"attack":1,"health":1,"split":0,"text":[]}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { validate } from "../schema.mjs";
|
||||
import { loadCatalog } from "../lib.mjs";
|
||||
@@ -52,6 +52,52 @@ test("card_schema_accepts_fixtures", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Card data the course ships: course/cards (every card the references load) and the
|
||||
// Expansion Drop answers in course/NN-*/expansion/solution/cards.
|
||||
function courseCardFiles() {
|
||||
const courseDir = join(here, "../../course");
|
||||
const dirs = [join(courseDir, "cards")];
|
||||
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
|
||||
const d = join(courseDir, m, "expansion/solution/cards");
|
||||
if (existsSync(d)) dirs.push(d);
|
||||
}
|
||||
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
|
||||
}
|
||||
|
||||
test("course_cards_valid_and_match_catalog", () => {
|
||||
const files = courseCardFiles();
|
||||
assert.ok(files.length >= 20, `found ${files.length} card files`);
|
||||
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
|
||||
const seen = new Set();
|
||||
for (const f of files) {
|
||||
const c = json(f);
|
||||
assert.deepEqual(validate(cardSchema, c), [], f);
|
||||
assert.equal(basename(f), c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".json", `${f}: file name is the card's slug`);
|
||||
assert.ok(!seen.has(c.name), `duplicate card file for ${c.name}`);
|
||||
seen.add(c.name);
|
||||
const m = cat.get(c.name);
|
||||
assert.ok(m && m.kind === "card", `${c.name} in catalog`);
|
||||
assert.deepEqual(
|
||||
[c.name, c.type, c.cost, c.attack, c.health, c.fictional ?? false],
|
||||
[m.name, m.type, m.cost, m.attack, m.health, m.fictional ?? false],
|
||||
`${f} matches catalog`,
|
||||
);
|
||||
assert.ok(c.split <= c.text.length, `${f}: split within text`);
|
||||
}
|
||||
// Every Hearthstone (and fictional) card in the catalog has its data.
|
||||
for (const e of cat.values()) if (e.kind === "card" && !e.game) assert.ok(seen.has(e.name), `no card file for ${e.name}`);
|
||||
});
|
||||
|
||||
test("card_schema_assemble_effect", () => {
|
||||
const spell = (effect) => ({ name: "X", type: "spell", cost: 3, split: 0, text: [{ kind: "effect", effect }] });
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "deathrattle" } })), []);
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: {} })), []);
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2, health: 2 })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble" })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "bogus" } })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2 })), []);
|
||||
});
|
||||
|
||||
test("card_schema_rejects_bad", () => {
|
||||
const bad = { name: "X", type: "minion", cost: 1, split: 0, text: [{ kind: "trigger", on: "bogus" }] };
|
||||
assert.notDeepEqual(validate(cardSchema, bad), []);
|
||||
|
||||
@@ -14,6 +14,13 @@ test("start_module_copies_tests", () => {
|
||||
assert.match(r.out, /newGame, summon, play/);
|
||||
});
|
||||
|
||||
test("start_module_copies_course_cards", () => {
|
||||
const ws = copyFixture("ws-start");
|
||||
const r = run("start-module.mjs", ["01", ws], env);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.ok(existsSync(join(ws, "cards/test-fixture.json")));
|
||||
});
|
||||
|
||||
test("start_module_uses_track", () => {
|
||||
const ws = copyFixture("ws-start-ts");
|
||||
assert.equal(run("start-module.mjs", ["01", ws], env).code, 0);
|
||||
|
||||
@@ -46,6 +46,17 @@ test("verify_stage_copies_all_earlier_module_tests", async () => {
|
||||
assert.ok(existsSync(join(dir, "Cargo.toml")));
|
||||
});
|
||||
|
||||
test("verify_stage_copies_course_cards", async () => {
|
||||
// From Module 5 the references load card data from <crate or package root>/cards.
|
||||
process.env.COURSE_ROOT = root;
|
||||
const { planVerification, stage } = await import("../verify-references.mjs");
|
||||
const { existsSync } = await import("node:fs");
|
||||
for (const lang of ["rust", "ts"]) {
|
||||
const dir = stage(planVerification(moduleDirs(), 1)[0], lang);
|
||||
assert.ok(existsSync(join(dir, "cards", "test-fixture.json")), lang);
|
||||
}
|
||||
});
|
||||
|
||||
test("verify_ts_default_installs_dependencies_first", async () => {
|
||||
// A staged reference has no node_modules; a bare `npx vitest` fetches the
|
||||
// latest vitest, which cannot load the reference's vitest.config.ts.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests.
|
||||
// course/cards (the card data, loaded by the references from Module 5) is staged as <dir>/cards.
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { moduleDirs, testFiles, fail } from "./lib.mjs";
|
||||
import { courseRoot, moduleDirs, testFiles, fail } from "./lib.mjs";
|
||||
|
||||
// A staged reference has no node_modules, so TS installs the reference's own
|
||||
// pinned devDependencies first (a bare `npx vitest` would fetch an unrelated version).
|
||||
@@ -35,6 +36,8 @@ export function stage(plan, lang) {
|
||||
for (const t of plan.testModules) {
|
||||
for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f)));
|
||||
}
|
||||
const cards = join(courseRoot(), "cards");
|
||||
if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# serde_json arrives at Module 5; nothing needed yet.
|
||||
# serde (with "derive") and serde_json arrive at Module 5; nothing needed yet.
|
||||
|
||||
@@ -8,4 +8,4 @@ Tests live in `tests/` and use `use engine::harness::*;`. Get a module's tests w
|
||||
cargo test
|
||||
```
|
||||
|
||||
The crate has no dependencies yet. `serde_json` is added at Module 5.
|
||||
The crate has no dependencies yet. `serde` (with the `derive` feature) and `serde_json` are added at Module 5, when cards become JSON files in `cards/`.
|
||||
|
||||
Reference in New Issue
Block a user