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

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

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

No files matched your search

@@ -10,7 +10,7 @@
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,19 +30,33 @@ impl Half {
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
@@ -62,12 +76,17 @@ pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef,
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
@@ -92,9 +111,11 @@ pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
@@ -111,9 +132,18 @@ pub fn assemble_from_pool(
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
@@ -122,22 +152,12 @@ pub fn assemble_from_pool(
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
@@ -147,7 +167,8 @@ pub fn assemble_from_pool(
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -258,6 +258,34 @@ impl Card {
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
@@ -20,13 +20,17 @@ pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
@@ -18,7 +18,9 @@
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
@@ -35,11 +37,11 @@ pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
RedirectTo, Stacking, Target, Trigger, TriggerOn,
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS, DEATH_PHASE_LIMIT};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -20,6 +20,11 @@
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
@@ -121,6 +126,10 @@ impl Ctx<'_> {
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
@@ -130,6 +139,11 @@ pub fn run_sequence(world: &mut World, action: Action) {
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
@@ -268,18 +282,27 @@ fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
for (source, index, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
@@ -295,7 +318,7 @@ fn react(world: &mut World, events: &[Event]) {
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block();
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
@@ -87,8 +87,10 @@ pub struct World {
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// How many triggers are resolving right now, one inside another.
depth: u32,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
@@ -105,7 +107,7 @@ impl World {
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
depth: 0,
resolving: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
@@ -304,17 +306,23 @@ impl World {
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.depth);
self.depths.push(self.resolving.len() as u32);
}
/// A trigger starts resolving: the events it causes are recorded one level deeper.
pub fn open_block(&mut self) {
self.depth += 1;
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.depth -= 1;
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.