feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks, the cumulative reference (Module 11's plus combat) and the drop. The Damage action carries per-target hits with a priority; react stable-sorts its Queue by priority before order of play, so the triggers answering the defender's Damage Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness contract, catalog, research notes and earlier lessons' forward pointers follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8fff05ccb
commit
fb9d0a4eb4
89 files changed
+11852
-6
No files matched your search
@@ -0,0 +1,174 @@
|
||||
//! 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::{well_formed, 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an Assemble was refused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssembleFailure {
|
||||
/// The new text would equal the base's.
|
||||
Unchanged,
|
||||
/// The new text would not be well-formed (see `well_formed`).
|
||||
IllFormed,
|
||||
}
|
||||
|
||||
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssembleError {
|
||||
pub base: String,
|
||||
pub donor: String,
|
||||
pub half: Half,
|
||||
pub reason: AssembleFailure,
|
||||
}
|
||||
|
||||
impl fmt::Display for AssembleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let result = match self.reason {
|
||||
AssembleFailure::Unchanged => "leaves its text unchanged",
|
||||
AssembleFailure::IllFormed => "gives ill-formed text",
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"assembling {:?} with the {} half of {:?} {result}",
|
||||
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();
|
||||
let refuse = |reason| AssembleError {
|
||||
base: base.name.clone(),
|
||||
donor: donor.name.clone(),
|
||||
half,
|
||||
reason,
|
||||
};
|
||||
if text == base.text {
|
||||
return Err(refuse(AssembleFailure::Unchanged));
|
||||
}
|
||||
if !well_formed(&text, base.card_type) {
|
||||
return Err(refuse(AssembleFailure::IllFormed));
|
||||
}
|
||||
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, and the half to replace. Only combinations that change the
|
||||
/// base's text and leave it well-formed are offered: a donor is offered if one of its
|
||||
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
|
||||
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
|
||||
/// donors; here every choice is the chooser's.
|
||||
pub fn assemble_from_pool(
|
||||
world: &mut World,
|
||||
player: PlayerId,
|
||||
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();
|
||||
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
|
||||
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
|
||||
let donors_for = |base: &CardDef| -> Vec<String> {
|
||||
pool.iter()
|
||||
.map(|(d, _)| d)
|
||||
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
|
||||
.map(|d| d.name.clone())
|
||||
.collect()
|
||||
};
|
||||
let bases: Vec<String> = pool
|
||||
.iter()
|
||||
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
|
||||
.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 donor_name = world.choose("donor", &donors_for(&base));
|
||||
let donor = world.card(&donor_name).def.clone();
|
||||
|
||||
let halves: Vec<String> = Half::BOTH
|
||||
.iter()
|
||||
.filter(|&&h| changes(&base, &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 give a new, well-formed text are offered");
|
||||
let name = register_assembled(world, def);
|
||||
Some(world.create(&name, player, Zone::Hand))
|
||||
}
|
||||
Reference in new issue
Block a user