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.
@@ -36,11 +38,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.
@@ -8,20 +8,25 @@
// 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 { hasTrigger, wellFormed, 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. */
/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */
export type AssembleFailure = "unchanged" | "illFormed";
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
readonly reason: AssembleFailure,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text";
super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`);
this.name = "AssembleError";
}
}
@@ -51,10 +56,12 @@ export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
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);
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
@@ -82,23 +89,25 @@ export function registerAssembled(world: World, 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.
*/
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);
const donorsFor = (base: CardDef): string[] =>
pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
const bases = pool
.filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0)
.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 donor = world.card(world.choose("donor", donorsFor(base))).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
@@ -143,6 +143,28 @@ export function compile(def: CardDef, inPool: boolean): Card {
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/**
* 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.
*/
export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean {
if (type !== "minion") return true;
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
let open: "none" | "empty" | "filled" = "none";
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
@@ -19,8 +19,16 @@ export function actions(effect: Effect, ctx: Ctx): Action[] {
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 "summon": {
// Several tokens stand left to right in summon order: each goes right of the one before.
const at = summonPosition(ctx);
return Array.from({ length: effect.count }, (_, i) => ({
type: "summon" as const,
card: effect.card,
player,
at: at === undefined ? undefined : at + i,
}));
}
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
@@ -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.
@@ -40,7 +42,8 @@ export {
type Target,
type Trigger,
type TriggerOn,
wellFormed,
} from "./card";
export type { Event } from "./event";
export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { Ctx, DAMAGE_STEPS, DEATH_PHASE_LIMIT, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -20,6 +20,11 @@
// 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.
//
// 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.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther, redirectFor } from "./aura";
@@ -91,6 +96,12 @@ export class 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.
*/
export const DEATH_PHASE_LIMIT = 64;
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
@@ -98,6 +109,10 @@ export function runSequence(world: World, action: Action): void {
// 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++) {
// Between Death Phases no trigger is resolving, so the error leaves no block open.
if (round > DEATH_PHASE_LIMIT) {
throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`);
}
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
@@ -214,16 +229,20 @@ function applyDamage(world: World, target: EntityId, amount: number): Event {
/** 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][] = [];
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
const queue: [EntityId, number, 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]);
}
world.card(world.cardName(listener)).triggers.forEach((trigger, index) => {
if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) {
queue.push([listener, index, trigger, event]);
}
});
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
for (const [source, index, 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);
@@ -234,7 +253,7 @@ function react(world: World, events: Event[]): void {
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
world.openBlock(source, index);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
world.closeBlock();
}
@@ -49,8 +49,12 @@ export class World {
private events: Event[] = [];
/** The nesting depth at which each event in `events` was recorded, index for index. */
private depths: number[] = [];
/** How many triggers are resolving right now, one inside another. */
private depth = 0;
/**
* 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.
*/
private resolving: [EntityId, number][] = [];
/** 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. */
@@ -226,17 +230,25 @@ export class World {
record(event: Event): void {
this.events.push(event);
this.depths.push(this.depth);
this.depths.push(this.resolving.length);
}
/** A trigger starts resolving: the events it causes are recorded one level deeper. */
openBlock(): void {
this.depth++;
/**
* Trigger number `trigger` of `source` starts resolving: the events it causes are
* recorded one level deeper.
*/
openBlock(source: EntityId, trigger: number): void {
this.resolving.push([source, trigger]);
}
/** The trigger that last opened a block has finished resolving. */
closeBlock(): void {
this.depth--;
this.resolving.pop();
}
/** Is trigger number `trigger` of `source` resolving right now (at any depth)? */
isResolving(source: EntityId, trigger: number): boolean {
return this.resolving.some(([s, t]) => s === source && t === trigger);
}
/** Add a card to the library, or replace the card with the same name. */