diff --git a/crates/engine/src/game/effects/bounce.rs b/crates/engine/src/game/effects/bounce.rs index c4c951e027..309d2c561c 100644 --- a/crates/engine/src/game/effects/bounce.rs +++ b/crates/engine/src/game/effects/bounce.rs @@ -67,16 +67,37 @@ fn filter_uses_scoped_player(filter: &TargetFilter) -> bool { } } +/// Finds a spell's casting variant while it is on the stack or resolving. +/// +/// Resolving spells leave `GameState::stack` before their chained instructions +/// run, so the resolution carrier is also an authoritative source. fn stack_spell_casting_variant( state: &GameState, obj_id: crate::types::identifiers::ObjectId, ) -> Option { - state.stack.iter().find_map(|entry| match &entry.kind { - StackEntryKind::Spell { - casting_variant, .. - } if entry.id == obj_id => Some(*casting_variant), - _ => None, - }) + state + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::Spell { + casting_variant, .. + } if entry.id == obj_id => Some(*casting_variant), + _ => None, + }) + .or_else(|| { + // CR 608.2m + CR 608.2n: resolving spells are popped from the live + // stack before their effect chain runs, but their casting variant stays + // authoritative in the resolution carrier until the chain completes. + state + .resolving_stack_entry + .as_ref() + .and_then(|entry| match &entry.kind { + StackEntryKind::Spell { + casting_variant, .. + } if entry.id == obj_id => Some(*casting_variant), + _ => None, + }) + }) } /// CR 400.6: Zone change — return target object to the destination zone diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 1265fc71a9..dcfe486df2 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -196,6 +196,7 @@ fn parse_state_presence_conditions(input: &str) -> OracleResult<'_, StaticCondit // CR 402.1 + CR 602.5: existential "a player has ". parse_a_player_has_hand_predicate, parse_you_have_conditions, + parse_parent_target_controller_more_life_than_you, parse_that_player_has_conditions, // CR 205.3i + CR 404.1: additive two-term count threshold // ("the number of A plus the number of B is N or greater"). @@ -3590,6 +3591,83 @@ fn parse_that_player_has_conditions(input: &str) -> OracleResult<'_, StaticCondi ))) } +/// Parse a parent target's controller/player life comparison. +/// +/// The target grammar combines two referent kinds in one phrase: a player is +/// their own controller, while a planeswalker's controller is read from the +/// targeted object. `ParentObjectTargetController` is the existing shared +/// scope for that relation, so this one production covers both target arms. +/// CR 120.3a + CR 119.3 + CR 608.2c: noninfect damage to a player causes life +/// loss, while the later conditional reads a targeted planeswalker's +/// controller's current life after the preceding damage instruction. +fn parse_parent_target_controller_more_life_than_you( + input: &str, +) -> OracleResult<'_, StaticCondition> { + let (rest, _) = parse_player_or_planeswalker_controller_referent(input)?; + let (rest, _) = tag(" has ").parse(rest)?; + parse_more_life_than_you_comparison(rest) +} + +/// Parse the compound target-referent axis shared by player-or-planeswalker +/// effects. The sequence is deliberately ordered and fail-closed: reversing +/// its referents changes the Oracle sentence rather than expressing a variant +/// of the same target relation. +fn parse_player_or_planeswalker_controller_referent(input: &str) -> OracleResult<'_, ()> { + let (rest, first) = parse_parent_target_referent(input)?; + if !matches!(first, ParentTargetReferent::Player) { + return Err(oracle_err(input)); + } + let (rest, _) = tag(" or ").parse(rest)?; + let (rest, second) = parse_parent_target_referent(rest)?; + if !matches!(second, ParentTargetReferent::PlaneswalkerController) { + return Err(oracle_err(input)); + } + Ok((rest, ())) +} + +/// Parse the life-comparison axis after a target referent has been consumed. +fn parse_more_life_than_you_comparison(input: &str) -> OracleResult<'_, StaticCondition> { + let (rest, _) = tag("more life than you").parse(input)?; + Ok(( + rest, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::ParentObjectTargetController, + }, + }, + comparator: Comparator::GT, + rhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::Controller, + }, + }, + }, + )) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ParentTargetReferent { + Player, + PlaneswalkerController, +} + +/// Parse one `that ` axis from a player-or-planeswalker target +/// phrase. The caller composes the two referents around the shared `or` token. +fn parse_parent_target_referent(input: &str) -> OracleResult<'_, ParentTargetReferent> { + preceded( + tag("that "), + alt(( + value(ParentTargetReferent::Player, tag("player")), + value( + ParentTargetReferent::PlaneswalkerController, + terminated(tag("planeswalker"), tag("'s controller")), + ), + )), + ) + .parse(input) +} + /// Parse life-total predicates after a ` has ` prefix has been /// consumed. Returns `Some(condition)` on match. /// @@ -14766,6 +14844,64 @@ mod tests { } } + #[test] + fn parent_target_controller_life_comparison_parses_full_condition() { + let (rest, condition) = parse_inner_condition( + "that player or that planeswalker's controller has more life than you", + ) + .expect("the player-or-planeswalker-controller condition must parse"); + assert_eq!( + rest, "", + "the complete condition fragment must be consumed, not partially parsed" + ); + assert_eq!( + condition, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::ParentObjectTargetController, + }, + }, + comparator: Comparator::GT, + rhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::Controller, + }, + }, + }, + "positive reach guard: the combined referent must retain its parent-target-controller scope" + ); + } + + #[test] + fn parent_target_controller_life_comparison_rejects_reversed_or_malformed_referents() { + let (_, positive_reach_guard) = parse_inner_condition( + "that player or that planeswalker's controller has more life than you", + ) + .expect("the valid combined referent must reach the new condition grammar"); + assert!( + matches!( + positive_reach_guard, + StaticCondition::QuantityComparison { + comparator: Comparator::GT, + .. + } + ), + "positive reach guard must prove the rejection cases exercise this grammar family" + ); + + for malformed in [ + "that planeswalker's controller or that player has more life than you", + "that player or that planeswalker has more life than you", + "that player or that planeswalker's controller has more life than them", + ] { + assert!( + parse_inner_condition(malformed).is_err(), + "malformed combined referent must fail closed: {malformed:?}" + ); + } + } + #[test] fn test_opponent_has_more_life() { let (rest, c) = parse_inner_condition("an opponent has more life than you").unwrap(); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 4f72b6537a..410734c308 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -888,6 +888,7 @@ mod prepared_state_serde; mod primo_unbounded_fractal_counters; mod printed_ability_order; mod proliferate_zero_counter; +mod pulse_of_the_forge; mod punishing_punch_twice_subject_power; mod purged_source_attachment_count_lki; mod purged_source_attacked_this_turn_lki; diff --git a/crates/engine/tests/integration/pulse_of_the_forge.rs b/crates/engine/tests/integration/pulse_of_the_forge.rs new file mode 100644 index 0000000000..692f4c23aa --- /dev/null +++ b/crates/engine/tests/integration/pulse_of_the_forge.rs @@ -0,0 +1,194 @@ +//! Pulse of the Forge's conditional self-return must use the controller of its +//! chosen player-or-planeswalker target after dealing damage. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + AbilityCondition, Comparator, Effect, PlayerScope, QuantityExpr, QuantityRef, +}; +use engine::types::counter::CounterType; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P2: PlayerId = PlayerId(2); +const PULSE_OF_THE_FORGE: &str = "Pulse of the Forge deals 4 damage to target player or planeswalker. Then if that player or that planeswalker's controller has more life than you, return Pulse of the Forge to its owner's hand."; + +/// Verifies the parser preserves Pulse's damage and conditional self-return. +#[test] +fn pulse_of_the_forge_parses_damage_and_conditional_return_chain() { + let parsed = parse_oracle_text( + PULSE_OF_THE_FORGE, + "Pulse of the Forge", + &[], + &["Instant".into()], + &[], + ); + assert!( + parsed.parse_warnings.is_empty(), + "Pulse of the Forge must not emit parse warnings: {:?}", + parsed.parse_warnings + ); + + let ability = parsed + .abilities + .first() + .expect("Pulse of the Forge must parse a spell ability"); + assert!( + matches!(&*ability.effect, Effect::DealDamage { .. }), + "the first instruction must deal damage: {:?}", + ability.effect + ); + let return_to_hand = ability + .sub_ability + .as_ref() + .expect("the conditional return must remain chained after damage"); + assert!( + matches!( + &*return_to_hand.effect, + Effect::Bounce { + target: engine::types::ability::TargetFilter::SelfRef, + destination: None, + .. + } + ), + "the rider must return the spell to hand: {:?}", + return_to_hand.effect + ); + assert!( + matches!( + &return_to_hand.condition, + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::ParentObjectTargetController, + }, + }, + comparator: Comparator::GT, + rhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::Controller, + }, + }, + }) + ), + "the rider must compare the target/controller life total to the caster's: {:?}", + return_to_hand.condition + ); +} + +/// Adds a castable Pulse of the Forge to the active player's hand. +fn pulse_spell(scenario: &mut GameScenario) -> engine::types::identifiers::ObjectId { + scenario + .add_spell_to_hand_from_oracle(P0, "Pulse of the Forge", true, PULSE_OF_THE_FORGE) + .id() +} + +/// Adds a planeswalker owned by P2 for a subsequent P1 control-change fixture. +fn add_stolen_planeswalker(scenario: &mut GameScenario) -> engine::types::identifiers::ObjectId { + // P2 owns this object; the runtime setup changes its controller to P1. + // Both differ from P0, the spell's controller. + scenario + .add_creature(P2, "Borrowed Jace", 0, 0) + .as_planeswalker_with_loyalty("Jace", 6) + .id() +} + +/// Establishes P1 as the planeswalker's durable controller in the fixture. +fn set_controller(runner: &mut GameRunner, object: engine::types::identifiers::ObjectId) { + let planeswalker = runner + .state_mut() + .objects + .get_mut(&object) + .expect("planeswalker must exist"); + assert_eq!(planeswalker.owner, P2, "fixture must be owned by P2"); + // The scenario starts from a stable post-control-change board state. Layer + // evaluation restores `controller` from `base_controller`, so both fields + // must name P1 rather than merely mutating the derived controller. + planeswalker.base_controller = Some(P1); + planeswalker.controller = P1; + assert_eq!( + planeswalker.controller, P1, + "fixture must be controlled by P1, not its owner or the caster" + ); +} + +/// Asserts that Pulse dealt its full four damage to the planeswalker. +fn assert_planeswalker_damage_reached( + outcome: &engine::game::scenario::CastOutcome, + planeswalker: engine::types::identifiers::ObjectId, +) { + assert_eq!( + outcome.counters(planeswalker, CounterType::Loyalty), + 2, + "Pulse of the Forge must deal 4 damage to the targeted planeswalker" + ); +} + +/// Verifies Pulse returns to hand when the damaged player remains ahead. +#[test] +fn pulse_of_the_forge_returns_after_damaging_a_player_with_more_life() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P1, 25); + let spell = pulse_spell(&mut scenario); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + + outcome.assert_life_delta(P1, -4); + outcome.assert_zone(&[spell], Zone::Hand); +} + +/// Verifies Pulse stays in the graveyard when damage removes the player's lead. +#[test] +fn pulse_of_the_forge_stays_in_graveyard_after_player_damage_removes_life_lead() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P1, 24); + let spell = pulse_spell(&mut scenario); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_player(P1).resolve(); + + outcome.assert_life_delta(P1, -4); + outcome.assert_zone(&[spell], Zone::Graveyard); +} + +/// Verifies a targeted planeswalker's controller supplies Pulse's true gate. +#[test] +fn pulse_of_the_forge_uses_targeted_planeswalkers_controller_for_true_gate() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario + .at_phase(Phase::PreCombatMain) + .with_life(P0, 20) + .with_life(P1, 25) + .with_life(P2, 10); + let planeswalker = add_stolen_planeswalker(&mut scenario); + let spell = pulse_spell(&mut scenario); + let mut runner = scenario.build(); + set_controller(&mut runner, planeswalker); + + let outcome = runner.cast(spell).target_object(planeswalker).resolve(); + + assert_planeswalker_damage_reached(&outcome, planeswalker); + outcome.assert_zone(&[spell], Zone::Hand); +} + +/// Verifies a targeted planeswalker's controller supplies Pulse's false gate. +#[test] +fn pulse_of_the_forge_uses_targeted_planeswalkers_controller_for_false_gate() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario + .at_phase(Phase::PreCombatMain) + .with_life(P0, 20) + .with_life(P1, 10) + .with_life(P2, 25); + let planeswalker = add_stolen_planeswalker(&mut scenario); + let spell = pulse_spell(&mut scenario); + let mut runner = scenario.build(); + set_controller(&mut runner, planeswalker); + + let outcome = runner.cast(spell).target_object(planeswalker).resolve(); + + assert_planeswalker_damage_reached(&outcome, planeswalker); + outcome.assert_zone(&[spell], Zone::Graveyard); +} diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 607cbdab99..d9ad8497fa 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -1211,7 +1211,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Prompto Argentum - Prowling Geistcatcher - Pugnacious Hammerskull -- Pulse of the Forge - Pulse of the Hunter Maze - Qasali Ambusher - Quest for the Nihil Stone