feat: module 04 interceptors
Trigger multipliers as aura data on cards, snapshotted by an Aura Update (Other) after each Death Creation Step and on summon, and applied by one generic routine (activations) when each queued trigger starts. Baron Rivendare is content only (stacking "max"); Brann Bronzebeard is the content-only Expansion Drop. Adds play (now required) and Battlecry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a4eba1948
commit
bcdaca0cd2
40 files changed
+4421
-1
No files matched your search
@@ -0,0 +1,21 @@
|
||||
# Module 4 Expansion Drop: Brann Bronzebeard
|
||||
|
||||
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: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." ([Brann Bronzebeard, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)).
|
||||
- Tests: `tests/rust/test_04x.rs` and `tests/ts/test_04x.test.ts`. Copy the one for your track into your workspace's `tests/`.
|
||||
- Reference answer: `solution/rust/src/content/mod.rs` and `solution/ts/src/content/index.ts`. Each is the Module 4 reference content with one card added. Nothing under `src/engine/` changes, and neither does the harness.
|
||||
- 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.
|
||||
|
||||
The reference gives Brann the same stacking policy as Baron Rivendare (`"max"`). The course's sources don't state how two Branns combine, and this test uses one Brann, so the choice isn't checked.
|
||||
|
||||
## Scenario
|
||||
|
||||
### test_04x_brann_doubles_battlecry
|
||||
|
||||
- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Brann Bronzebeard` for player 0 (id B). Add `Novice Engineer` ("Battlecry: Draw a card.") to player 0's hand (id E). Call `clearLog()`.
|
||||
- Action: `play(E)`.
|
||||
- Assertions:
|
||||
- `hand(0)` has 2 cards; call them C1 and C2, in hand order.
|
||||
- The log is exactly, in this order: `Summoned{id: E}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C1}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C2}`. The Battlecry is queued once and activates twice back to back, exactly as a Deathrattle does under Baron Rivendare.
|
||||
- `board(0)` is `[B, E]`.
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Content: card definitions. Only this directory may name specific cards.
|
||||
//!
|
||||
//! Each effect is a small Rust function that returns engine actions. Module 5
|
||||
//! replaces these functions with card data the engine interprets. Auras are
|
||||
//! already data: the engine reads them, and no engine code knows these cards.
|
||||
|
||||
use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, TriggerOn};
|
||||
|
||||
/// Every card this game knows.
|
||||
pub fn library() -> CardLibrary {
|
||||
CardLibrary::new(vec![
|
||||
// Fictional test minion with no text.
|
||||
CardDef::minion("Test Filler", 1, 1),
|
||||
// "Whenever a friendly minion takes damage, gain 1 Armor."
|
||||
CardDef::minion("Armorsmith", 1, 4).on(TriggerOn::FriendlyMinionDamaged, |c| {
|
||||
vec![Action::GainArmor {
|
||||
player: c.owner,
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// "Whenever this minion takes damage, draw a card."
|
||||
CardDef::minion("Acolyte of Pain", 1, 4).on(TriggerOn::SelfDamaged, |c| {
|
||||
vec![Action::Draw { player: c.owner }]
|
||||
}),
|
||||
// Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero."
|
||||
CardDef::minion("Test Echo", 1, 5).on(TriggerOn::AnyMinionDamaged, |c| {
|
||||
vec![Action::Damage {
|
||||
targets: vec![c.enemy_hero()],
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// Fictional: "Whenever a hero takes damage, gain +1 Attack."
|
||||
CardDef::minion("Test Reactor", 1, 5).on(TriggerOn::HeroDamaged, |c| {
|
||||
vec![Action::GainAttack {
|
||||
id: c.minion(),
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// "Deathrattle: Draw a card."
|
||||
CardDef::minion("Loot Hoarder", 2, 1).on(TriggerOn::Deathrattle, |c| {
|
||||
vec![Action::Draw { player: c.owner }]
|
||||
}),
|
||||
// "Deathrattle: Summon a 2/1 Damaged Golem."
|
||||
CardDef::minion("Harvest Golem", 2, 3).on(TriggerOn::Deathrattle, |c| {
|
||||
vec![Action::Summon {
|
||||
card: "Damaged Golem".to_string(),
|
||||
player: c.owner,
|
||||
at: c.position,
|
||||
}]
|
||||
}),
|
||||
// Summoned by Harvest Golem; no text.
|
||||
CardDef::minion("Damaged Golem", 2, 1),
|
||||
// "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt.
|
||||
CardDef::minion("Unstable Ghoul", 1, 3).on(TriggerOn::Deathrattle, |c| {
|
||||
vec![Action::Damage {
|
||||
targets: c.world.minions_in_play(),
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
|
||||
CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier {
|
||||
trigger: TriggerOn::Deathrattle,
|
||||
times: 2,
|
||||
stacking: Stacking::Max,
|
||||
}),
|
||||
// Expansion Drop: "Your Battlecries trigger twice."
|
||||
CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier {
|
||||
trigger: TriggerOn::Battlecry,
|
||||
times: 2,
|
||||
stacking: Stacking::Max,
|
||||
}),
|
||||
// "Battlecry: Draw a card."
|
||||
CardDef::minion("Novice Engineer", 1, 1).on(TriggerOn::Battlecry, |c| {
|
||||
vec![Action::Draw { player: c.owner }]
|
||||
}),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
CardDef::spell("Whirlwind", |c| {
|
||||
vec![Action::Damage {
|
||||
targets: c.world.minions_in_play(),
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.)
|
||||
CardDef::spell("Twisting Nether", |c| {
|
||||
vec![Action::Destroy {
|
||||
targets: c.world.minions_in_play(),
|
||||
}]
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Content: card definitions. Only this directory may name specific cards.
|
||||
//
|
||||
// Each effect is a small function that returns engine actions. Module 5
|
||||
// replaces these functions with card data the engine interprets. Auras are
|
||||
// already data: the engine reads them, and no engine code knows these cards.
|
||||
|
||||
import { CardLibrary, minion, spell } from "../engine";
|
||||
|
||||
/** Every card this game knows. */
|
||||
export function library(): CardLibrary {
|
||||
return new CardLibrary([
|
||||
// Fictional test minion with no text.
|
||||
minion("Test Filler", 1, 1),
|
||||
// "Whenever a friendly minion takes damage, gain 1 Armor."
|
||||
minion("Armorsmith", 1, 4, [
|
||||
{ on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] },
|
||||
]),
|
||||
// "Whenever this minion takes damage, draw a card."
|
||||
minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero."
|
||||
minion("Test Echo", 1, 5, [
|
||||
{ on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] },
|
||||
]),
|
||||
// Fictional: "Whenever a hero takes damage, gain +1 Attack."
|
||||
minion("Test Reactor", 1, 5, [
|
||||
{ on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] },
|
||||
]),
|
||||
// "Deathrattle: Draw a card."
|
||||
minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// "Deathrattle: Summon a 2/1 Damaged Golem."
|
||||
minion("Harvest Golem", 2, 3, [
|
||||
{
|
||||
on: "deathrattle",
|
||||
effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }],
|
||||
},
|
||||
]),
|
||||
// Summoned by Harvest Golem; no text.
|
||||
minion("Damaged Golem", 2, 1),
|
||||
// "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt.
|
||||
minion("Unstable Ghoul", 1, 3, [
|
||||
{
|
||||
on: "deathrattle",
|
||||
effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }],
|
||||
},
|
||||
]),
|
||||
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
|
||||
minion("Baron Rivendare", 1, 7, [], [
|
||||
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
|
||||
]),
|
||||
// Expansion Drop: "Your Battlecries trigger twice."
|
||||
minion("Brann Bronzebeard", 2, 4, [], [
|
||||
{ type: "triggerMultiplier", trigger: "battlecry", times: 2, stacking: "max" },
|
||||
]),
|
||||
// "Battlecry: Draw a card."
|
||||
minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]),
|
||||
// "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.)
|
||||
spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Module 4 Expansion Drop: Brann Bronzebeard. Matches expansion/README.md.
|
||||
use engine::harness::*;
|
||||
|
||||
#[test]
|
||||
fn test_04x_brann_doubles_battlecry() {
|
||||
let mut g = new_game(GameOpts {
|
||||
decks: Some([vec!["Test Filler".to_string(); 5], vec![]]),
|
||||
});
|
||||
let brann = g.summon("Brann Bronzebeard", 0);
|
||||
let e = g.add_to_hand("Novice Engineer", 0);
|
||||
g.clear_log();
|
||||
|
||||
g.play(e);
|
||||
|
||||
let hand = g.hand(0);
|
||||
assert_eq!(hand.len(), 2);
|
||||
let (c1, c2) = (hand[0], hand[1]);
|
||||
let battlecry = GameEvent::Triggered {
|
||||
source: e,
|
||||
trigger: "battlecry".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
g.log(),
|
||||
vec![
|
||||
GameEvent::Summoned { id: e },
|
||||
battlecry.clone(),
|
||||
GameEvent::Drew { player: 0, id: c1 },
|
||||
battlecry,
|
||||
GameEvent::Drew { player: 0, id: c2 },
|
||||
]
|
||||
);
|
||||
assert_eq!(g.board(0), vec![brann, e]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Module 4 Expansion Drop: Brann Bronzebeard. Matches expansion/README.md.
|
||||
import { expect, test } from "vitest";
|
||||
import { newGame } from "../src/harness";
|
||||
|
||||
test("test_04x_brann_doubles_battlecry", () => {
|
||||
const g = newGame({ decks: [Array(5).fill("Test Filler"), []] });
|
||||
const brann = g.summon("Brann Bronzebeard", 0);
|
||||
const e = g.addToHand("Novice Engineer", 0);
|
||||
g.clearLog();
|
||||
|
||||
g.play(e);
|
||||
|
||||
const hand = g.hand(0);
|
||||
expect(hand).toHaveLength(2);
|
||||
const [c1, c2] = hand;
|
||||
expect(g.log()).toEqual([
|
||||
{ type: "Summoned", id: e },
|
||||
{ type: "Triggered", source: e, trigger: "battlecry" },
|
||||
{ type: "Drew", player: 0, id: c1 },
|
||||
{ type: "Triggered", source: e, trigger: "battlecry" },
|
||||
{ type: "Drew", player: 0, id: c2 },
|
||||
]);
|
||||
expect(g.board(0)).toEqual([brann, e]);
|
||||
});
|
||||
Reference in new issue
Block a user