From 33a0123c802478c208fbbc9f53140fae570bc3aa Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:49:17 +0200 Subject: [PATCH 1/2] fix: Inferno Titan trigger cannot divide damage --- crates/engine/src/game/ability_utils.rs | 53 ++++++++++++- crates/engine/src/parser/oracle_trigger.rs | 1 + crates/engine/tests/integration/main.rs | 1 + .../tests/integration/zz_inferno_diag.rs | 74 +++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/zz_inferno_diag.rs diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index f2b7480555..382e003d46 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -8736,13 +8736,62 @@ mod tests { assert_eq!(slots.len(), 2, "non-distributing ability is untouched"); } + /// DIAGNOSTIC for issue #3681 (Inferno Titan): "deals 3 damage divided as you + /// choose among one, two, or three targets" parses to DealDamage{amount:3, + /// target:Any} + multi_target=fixed(1,3) + distribute=Damage. This checks how + /// many target slots build_target_slots surfaces for that exact ability. #[test] - fn build_target_slots_resolves_dynamic_multi_target_max() { + fn zz_debug_inferno_titan_slots() { + use crate::types::game_state::DistributionUnit; let mut state = crate::types::game_state::GameState::new_two_player(42); + // Two legal targets on the battlefield (creatures) + a player. for index in 0..3 { let creature = crate::game::zones::create_object( &mut state, - crate::types::identifiers::CardId(index + 1), + crate::types::identifiers::CardId(100 + index as u64), + PlayerId(1), + format!("Opp creature {index}"), + crate::types::zones::Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .unwrap() + .card_types + .core_types + .push(crate::types::card_type::CoreType::Creature); + } + let mut ability = ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Any, + damage_source: None, + }, + vec![], + ObjectId(10), + PlayerId(0), + ); + ability.multi_target = Some(crate::types::ability::MultiTargetSpec::fixed(1, 3)); + let slots = build_target_slots(&state, &ability).expect("slots should build"); + println!( + "ZZ_SLOT_DEBUG slot_count={} optional={:?}", + slots.len(), + slots.iter().map(|s| s.optional).collect::>() + ); + for (i, s) in slots.iter().enumerate() { + println!("ZZ_SLOT_DEBUG slot[{i}] legal_targets={}", s.legal_targets.len()); + } + // Also test the variant WITH distribute flag to mirror the real trigger: + let _ = DistributionUnit::Damage; + panic!("ZZ_SLOT_DEBUG done"); + } + + #[test] + fn build_target_slots_resolves_dynamic_multi_target_max() { + let mut state = crate::types::game_state::GameState::new_two_player(42); + for index in 0..3 { + let creature = crate::game::zones::create_object( + &mut state, crate::types::identifiers::CardId(index + 1), PlayerId(0), format!("Creature {index}"), Zone::Battlefield, diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index ccd622585c..b06cff2b1e 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -12485,6 +12485,7 @@ mod tests { PlayerFilter, PlayerScope, PtStat, PtValue, PtValueScope, QuantityExpr, QuantityRef, SharedQuality, TapStateChange, TargetFilter, TypeFilter, TypedFilter, ZoneRef, }; + use crate::types::counter::{CounterMatch, CounterType}; use crate::types::game_state::WaitingFor; use crate::types::keywords::Keyword; diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 06bbf8ae4c..6d588061bc 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -413,3 +413,4 @@ mod wise_mothman_milled_trigger; mod wise_mothman_target_distinctness; mod wrenn_and_six_up_to_one_optout; mod yuriko_combat_damage; +mod zz_inferno_diag; diff --git a/crates/engine/tests/integration/zz_inferno_diag.rs b/crates/engine/tests/integration/zz_inferno_diag.rs new file mode 100644 index 0000000000..a345f20cdb --- /dev/null +++ b/crates/engine/tests/integration/zz_inferno_diag.rs @@ -0,0 +1,74 @@ +//! DIAGNOSTIC for issue #3681 (Inferno Titan divided damage trigger). + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const INFERNO_ORACLE: &str = + "Whenever this creature enters or attacks, it deals 3 damage divided as you choose among one, two, or three targets."; + +#[test] +fn zz_inferno_etb_trigger_target_flow() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Two legal creature targets for the opponent + the opponent player. + let _bear = scenario.add_creature(P1, "Bear", 2, 2).id(); + let _elf = scenario.add_creature(P1, "Elf", 1, 1).id(); + + let titan = scenario + .add_creature_to_hand_from_oracle(P0, "Inferno Titan", 6, 6, INFERNO_ORACLE) + .with_mana_cost(engine::types::mana::ManaCost::Cost { + generic: 4, + shards: vec![engine::types::mana::ManaCostShard::Red, engine::types::mana::ManaCostShard::Red], + }) + .id(); + + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Red, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ); 8], + ); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&titan].card_id; + runner + .act(GameAction::CastSpell { + object_id: titan, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("begin casting Inferno Titan"); + + // Drain mana-payment / cast flow until the ETB trigger target selection surfaces. + for step in 0..60 { + let wf = runner.state().waiting_for.clone(); + println!("ZZ_FLOW step={step} waiting_for={}", wf.variant_name()); + match wf { + WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => { + runner.pass_both_players(); + } + WaitingFor::Priority { .. } => break, + WaitingFor::TriggerTargetSelection { target_slots, .. } => { + println!("ZZ_FLOW TriggerTargetSelection slots={}", target_slots.len()); + for (i, s) in target_slots.iter().enumerate() { + println!("ZZ_FLOW slot[{i}] optional={} n_legal={}", s.optional, s.legal_targets.len()); + } + break; + } + WaitingFor::DistributeAmong { total, targets, .. } => { + println!("ZZ_FLOW DistributeAmong total={total} n_targets={}", targets.len()); + break; + } + _ => runner.pass_both_players(), + } + } + panic!("ZZ_FLOW done"); +} From 9e957862f244c6dd2f4d7ca1c27a4a3eb515e242 Mon Sep 17 00:00:00 2001 From: Jonathanchang31 <55106972+jonathanchang31@users.noreply.github.com> Date: Fri, 19 Jun 2026 05:45:50 +0200 Subject: [PATCH 2/2] fix: Inferno Titan divided-damage trigger target selection (#3681) Frontend bug: TargetingOverlay always rendered "one target" for any TriggerTargetSelection regardless of slot count, so players believed they could only pick one of the 1-3 divided-damage targets. Multi-slot triggers now show "Choose target N of M", matching spell target selection (CR 601.2d + CR 603.3d). - Remove panicking zz_* diagnostics that failed CI (test + rustfmt). - Add engine regression test: 3 targets -> DistributeAmong total=3 -> 1/1/1 split applies correct damage, plus single-target full-3 path. - Fix TargetingOverlay multi-slot prompt; add frontend tests. --- .../components/targeting/TargetingOverlay.tsx | 10 +- .../__tests__/TargetingOverlay.test.tsx | 78 ++++++ crates/engine/src/game/ability_utils.rs | 53 +--- crates/engine/src/parser/oracle_trigger.rs | 1 - ...issue_3681_inferno_titan_divided_damage.rs | 230 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 2 +- .../tests/integration/zz_inferno_diag.rs | 74 ------ 7 files changed, 318 insertions(+), 130 deletions(-) create mode 100644 crates/engine/tests/integration/issue_3681_inferno_titan_divided_damage.rs delete mode 100644 crates/engine/tests/integration/zz_inferno_diag.rs diff --git a/client/src/components/targeting/TargetingOverlay.tsx b/client/src/components/targeting/TargetingOverlay.tsx index 3882f710d9..610bca19de 100644 --- a/client/src/components/targeting/TargetingOverlay.tsx +++ b/client/src/components/targeting/TargetingOverlay.tsx @@ -257,10 +257,14 @@ function buildInferredTargetPrompt({ const targetWord = inferTargetNoun(activeSlot.legal_targets, objects, t); const useUpToOne = selection && targetSlots.length === 1 && activeSlot.optional; + // CR 601.2d + CR 603.3d: Both spell target selection (`TargetSelection`) and + // triggered target selection (`TriggerTargetSelection`) can carry multiple + // slots — e.g. Inferno Titan's "divided as you choose among one, two, or three + // targets" surfaces three slots. The prompt must reflect that so the controller + // knows additional targets remain ("target 2 of 3"), instead of always reading + // "one target" and misleading the player into stopping early. let prompt: string; - if (waitingFor.type === "TriggerTargetSelection") { - prompt = useUpToOne ? t("targeting.upToOne", { target: targetWord }) : t("targeting.one", { target: targetWord }); - } else if (targetSlots.length <= 1) { + if (targetSlots.length <= 1) { prompt = useUpToOne ? t("targeting.upToOne", { target: targetWord }) : t("targeting.one", { target: targetWord }); } else { prompt = t("targeting.chooseTargetOf", { current: Math.min(selection.current_slot + 1, targetSlots.length), total: targetSlots.length }); diff --git a/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx b/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx index 6bdc10e353..30b927a228 100644 --- a/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx +++ b/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx @@ -511,4 +511,82 @@ describe("TargetingOverlay", () => { expect(screen.getByText("a player")).toBeInTheDocument(); expect(screen.queryByText(/—/)).toBeNull(); }); + + // Regression for issue #3681 (Inferno Titan): a trigger that divides an effect + // among "one, two, or three targets" surfaces multiple slots. The prompt must + // report progress ("Choose target 1 of 3") instead of always reading + // "a creature", which misled players into selecting only one target. + it("shows 'Choose target N of M' for a multi-slot trigger (divide among targets)", () => { + const dispatch = vi.fn().mockResolvedValue([]); + const bear = buildGameObjectWithCoreTypes(["Creature"], { id: 7, name: "Bear" }); + const elf = buildGameObjectWithCoreTypes(["Creature"], { id: 8, name: "Elf" }); + const titan = buildGameObjectWithCoreTypes(["Creature"], { id: 9, name: "Inferno Titan" }); + const legal = [{ Object: 7 }, { Object: 8 }, { Object: 9 }, { Player: 1 }]; + + const gameState = createGameState({ + objects: { "7": bear, "8": elf, "9": titan }, + waiting_for: { + type: "TriggerTargetSelection", + data: { + player: 0, + target_slots: [ + { legal_targets: legal, optional: false }, + { legal_targets: legal, optional: true }, + { legal_targets: legal, optional: true }, + ], + selection: { current_slot: 0, current_legal_targets: legal }, + source_id: 9, + }, + }, + }); + + act(() => { + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch, + }); + }); + + render(); + + expect(screen.getByText("Choose target 1 of 3")).toBeInTheDocument(); + expect(screen.queryByText("a creature")).toBeNull(); + }); + + it("advances the slot progress as each target is chosen for a multi-slot trigger", () => { + const dispatch = vi.fn().mockResolvedValue([]); + const legal = [{ Object: 7 }, { Object: 8 }, { Player: 1 }]; + + const gameState = createGameState({ + objects: { + "7": buildGameObjectWithCoreTypes(["Creature"], { id: 7, name: "Bear" }), + "8": buildGameObjectWithCoreTypes(["Creature"], { id: 8, name: "Elf" }), + }, + waiting_for: { + type: "TriggerTargetSelection", + data: { + player: 0, + target_slots: [ + { legal_targets: legal, optional: false }, + { legal_targets: legal, optional: true }, + { legal_targets: legal, optional: true }, + ], + selection: { current_slot: 1, current_legal_targets: legal }, + }, + }, + }); + + act(() => { + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch, + }); + }); + + render(); + + expect(screen.getByText("Choose target 2 of 3")).toBeInTheDocument(); + }); }); diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 382e003d46..f2b7480555 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -8736,62 +8736,13 @@ mod tests { assert_eq!(slots.len(), 2, "non-distributing ability is untouched"); } - /// DIAGNOSTIC for issue #3681 (Inferno Titan): "deals 3 damage divided as you - /// choose among one, two, or three targets" parses to DealDamage{amount:3, - /// target:Any} + multi_target=fixed(1,3) + distribute=Damage. This checks how - /// many target slots build_target_slots surfaces for that exact ability. - #[test] - fn zz_debug_inferno_titan_slots() { - use crate::types::game_state::DistributionUnit; - let mut state = crate::types::game_state::GameState::new_two_player(42); - // Two legal targets on the battlefield (creatures) + a player. - for index in 0..3 { - let creature = crate::game::zones::create_object( - &mut state, - crate::types::identifiers::CardId(100 + index as u64), - PlayerId(1), - format!("Opp creature {index}"), - crate::types::zones::Zone::Battlefield, - ); - state - .objects - .get_mut(&creature) - .unwrap() - .card_types - .core_types - .push(crate::types::card_type::CoreType::Creature); - } - let mut ability = ResolvedAbility::new( - Effect::DealDamage { - amount: QuantityExpr::Fixed { value: 3 }, - target: TargetFilter::Any, - damage_source: None, - }, - vec![], - ObjectId(10), - PlayerId(0), - ); - ability.multi_target = Some(crate::types::ability::MultiTargetSpec::fixed(1, 3)); - let slots = build_target_slots(&state, &ability).expect("slots should build"); - println!( - "ZZ_SLOT_DEBUG slot_count={} optional={:?}", - slots.len(), - slots.iter().map(|s| s.optional).collect::>() - ); - for (i, s) in slots.iter().enumerate() { - println!("ZZ_SLOT_DEBUG slot[{i}] legal_targets={}", s.legal_targets.len()); - } - // Also test the variant WITH distribute flag to mirror the real trigger: - let _ = DistributionUnit::Damage; - panic!("ZZ_SLOT_DEBUG done"); - } - #[test] fn build_target_slots_resolves_dynamic_multi_target_max() { let mut state = crate::types::game_state::GameState::new_two_player(42); for index in 0..3 { let creature = crate::game::zones::create_object( - &mut state, crate::types::identifiers::CardId(index + 1), + &mut state, + crate::types::identifiers::CardId(index + 1), PlayerId(0), format!("Creature {index}"), Zone::Battlefield, diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index b06cff2b1e..ccd622585c 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -12485,7 +12485,6 @@ mod tests { PlayerFilter, PlayerScope, PtStat, PtValue, PtValueScope, QuantityExpr, QuantityRef, SharedQuality, TapStateChange, TargetFilter, TypeFilter, TypedFilter, ZoneRef, }; - use crate::types::counter::{CounterMatch, CounterType}; use crate::types::game_state::WaitingFor; use crate::types::keywords::Keyword; diff --git a/crates/engine/tests/integration/issue_3681_inferno_titan_divided_damage.rs b/crates/engine/tests/integration/issue_3681_inferno_titan_divided_damage.rs new file mode 100644 index 0000000000..8b4dd567a4 --- /dev/null +++ b/crates/engine/tests/integration/issue_3681_inferno_titan_divided_damage.rs @@ -0,0 +1,230 @@ +//! Issue #3681: Inferno Titan's "Whenever this creature enters or attacks, it +//! deals 3 damage divided as you choose among one, two, or three targets" +//! trigger must let the controller choose up to three targets and split the 3 +//! damage among them. +//! +//! The engine surfaces a `TriggerTargetSelection` with three slots (one +//! required, two optional) and then a `DistributeAmong { total: 3 }` step. This +//! test drives the full ETB flow end-to-end through the real parser + runtime: +//! cast Inferno Titan, choose three distinct targets (two creatures and a +//! player), distribute 1/1/1, and assert each target takes exactly its share. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +/// Canonical Oracle text (verified against client/public/card-data.json). +const INFERNO_ORACLE: &str = + "Whenever this creature enters or attacks, it deals 3 damage divided as you choose among one, two, or three targets."; + +/// Give `player` `count` red mana units so the {4}{R}{R} cast auto-pays. +fn add_red_mana( + runner: &mut engine::game::scenario::GameRunner, + player: engine::types::PlayerId, + count: usize, +) { + let dummy = engine::types::identifiers::ObjectId(0); + let pool = &mut runner + .state_mut() + .players + .iter_mut() + .find(|p| p.id == player) + .unwrap() + .mana_pool; + for _ in 0..count { + pool.add(ManaUnit::new(ManaType::Red, dummy, false, vec![])); + } +} + +/// Advance through the cast/payment flow until the ETB trigger surfaces its +/// target-selection prompt, passing priority as needed. +fn advance_to_trigger_target_selection( + runner: &mut engine::game::scenario::GameRunner, +) -> WaitingFor { + let mut guard = 0; + loop { + guard += 1; + assert!( + guard < 80, + "Inferno Titan ETB trigger never surfaced a target prompt; last waiting_for = {:?}", + runner.state().waiting_for + ); + match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { .. } => { + return runner.state().waiting_for.clone(); + } + WaitingFor::Priority { .. } => { + runner.pass_both_players(); + } + other => { + // Any other interactive state is unexpected for this scenario. + panic!("unexpected waiting_for while reaching ETB trigger: {other:?}"); + } + } + } +} + +#[test] +fn inferno_titan_etb_divides_damage_across_three_targets() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Two legal creature targets controlled by the opponent, plus the opponent + // player themselves — three distinct "any target" choices. + let bear = scenario.add_creature(P1, "Bear", 2, 2).id(); + let elf = scenario.add_creature(P1, "Elf", 1, 1).id(); + + let titan = scenario + .add_creature_to_hand_from_oracle(P0, "Inferno Titan", 6, 6, INFERNO_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Red], + generic: 4, + }) + .id(); + + let mut runner = scenario.build(); + add_red_mana(&mut runner, P0, 8); + + // Begin casting Inferno Titan (auto-pay the {4}{R}{R}). + let card_id = runner.state().objects[&titan].card_id; + runner + .act(GameAction::CastSpell { + object_id: titan, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Inferno Titan should be accepted"); + + // Reach the ETB trigger's target-selection prompt. + let waiting_for = advance_to_trigger_target_selection(&mut runner); + let WaitingFor::TriggerTargetSelection { target_slots, .. } = &waiting_for else { + panic!("expected TriggerTargetSelection, got {waiting_for:?}"); + }; + + // CR 601.2d + CR 603.3d: "among one, two, or three targets" surfaces exactly + // three slots — one required, two optional — so the controller may divide + // among up to three targets. + assert_eq!( + target_slots.len(), + 3, + "Inferno Titan must offer three target slots (1 required + 2 optional)" + ); + assert!( + !target_slots[0].optional, + "first slot must be required (minimum one target)" + ); + assert!(target_slots[1].optional, "second slot must be optional"); + assert!(target_slots[2].optional, "third slot must be optional"); + + // Choose all three distinct targets in one selection. + runner + .act(GameAction::SelectTargets { + targets: vec![ + TargetRef::Object(bear), + TargetRef::Object(elf), + TargetRef::Player(P1), + ], + }) + .expect("selecting three distinct targets should be accepted"); + + // After targets are chosen, the engine must prompt for the division. + match runner.state().waiting_for.clone() { + WaitingFor::DistributeAmong { total, targets, .. } => { + assert_eq!(total, 3, "damage pool to divide must be 3"); + assert_eq!( + targets.len(), + 3, + "all three chosen targets must participate in the distribution" + ); + } + other => panic!("expected DistributeAmong after target selection, got {other:?}"), + } + + // Distribute 1 / 1 / 1 across the three targets. + runner + .act(GameAction::DistributeAmong { + distribution: vec![ + (TargetRef::Object(bear), 1), + (TargetRef::Object(elf), 1), + (TargetRef::Player(P1), 1), + ], + }) + .expect("1/1/1 distribution should be accepted"); + + // Let the triggered ability resolve. + runner.advance_until_stack_empty(); + + // CR 120.3: each target takes exactly its assigned portion. + assert_eq!( + runner.state().objects[&bear].damage_marked, + 1, + "Bear must take 1 damage" + ); + assert_eq!( + runner.state().objects[&elf].damage_marked, + 1, + "Elf must take 1 damage" + ); + let p1_life = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .map(|p| p.life) + .expect("P1 must exist"); + assert_eq!(p1_life, 19, "opponent must lose 1 life (20 - 1 damage)"); +} + +/// CR 601.2d: the controller may also choose a SINGLE target and assign the +/// full 3 damage to it. This locks the "one target" branch of the divided-damage +/// trigger against regressions. +#[test] +fn inferno_titan_etb_can_assign_all_damage_to_one_target() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let bear = scenario.add_creature(P1, "Bear", 0, 5).id(); + + let titan = scenario + .add_creature_to_hand_from_oracle(P0, "Inferno Titan", 6, 6, INFERNO_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Red], + generic: 4, + }) + .id(); + + let mut runner = scenario.build(); + add_red_mana(&mut runner, P0, 8); + + let card_id = runner.state().objects[&titan].card_id; + runner + .act(GameAction::CastSpell { + object_id: titan, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Inferno Titan should be accepted"); + + advance_to_trigger_target_selection(&mut runner); + + // Choose only the one required target, skipping the two optional slots. + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(bear)], + }) + .expect("selecting a single target should be accepted"); + + // With a single target the engine assigns the full pool (no division prompt). + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().objects[&bear].damage_marked, + 3, + "single target must take the full 3 damage" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 6d588061bc..89ab68773b 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -269,6 +269,7 @@ mod issue_3325_umbral_mantle; mod issue_3425_legend_rule_exemption_scopes; mod issue_3660_paradigm_multiple_offers; mod issue_3665_smugglers_share; +mod issue_3681_inferno_titan_divided_damage; mod issue_536_six_grants_retrace; mod issue_541_endurance_graveyard_to_bottom; mod issue_544_krark_clan_ironworks_auto_pass; @@ -413,4 +414,3 @@ mod wise_mothman_milled_trigger; mod wise_mothman_target_distinctness; mod wrenn_and_six_up_to_one_optout; mod yuriko_combat_damage; -mod zz_inferno_diag; diff --git a/crates/engine/tests/integration/zz_inferno_diag.rs b/crates/engine/tests/integration/zz_inferno_diag.rs deleted file mode 100644 index a345f20cdb..0000000000 --- a/crates/engine/tests/integration/zz_inferno_diag.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! DIAGNOSTIC for issue #3681 (Inferno Titan divided damage trigger). - -use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::actions::GameAction; -use engine::types::game_state::{CastPaymentMode, WaitingFor}; -use engine::types::mana::{ManaType, ManaUnit}; -use engine::types::phase::Phase; - -const INFERNO_ORACLE: &str = - "Whenever this creature enters or attacks, it deals 3 damage divided as you choose among one, two, or three targets."; - -#[test] -fn zz_inferno_etb_trigger_target_flow() { - let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); - - // Two legal creature targets for the opponent + the opponent player. - let _bear = scenario.add_creature(P1, "Bear", 2, 2).id(); - let _elf = scenario.add_creature(P1, "Elf", 1, 1).id(); - - let titan = scenario - .add_creature_to_hand_from_oracle(P0, "Inferno Titan", 6, 6, INFERNO_ORACLE) - .with_mana_cost(engine::types::mana::ManaCost::Cost { - generic: 4, - shards: vec![engine::types::mana::ManaCostShard::Red, engine::types::mana::ManaCostShard::Red], - }) - .id(); - - scenario.with_mana_pool( - P0, - vec![ManaUnit::new( - ManaType::Red, - engine::types::identifiers::ObjectId(0), - false, - vec![], - ); 8], - ); - - let mut runner = scenario.build(); - let card_id = runner.state().objects[&titan].card_id; - runner - .act(GameAction::CastSpell { - object_id: titan, - card_id, - targets: vec![], - payment_mode: CastPaymentMode::Auto, - }) - .expect("begin casting Inferno Titan"); - - // Drain mana-payment / cast flow until the ETB trigger target selection surfaces. - for step in 0..60 { - let wf = runner.state().waiting_for.clone(); - println!("ZZ_FLOW step={step} waiting_for={}", wf.variant_name()); - match wf { - WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => { - runner.pass_both_players(); - } - WaitingFor::Priority { .. } => break, - WaitingFor::TriggerTargetSelection { target_slots, .. } => { - println!("ZZ_FLOW TriggerTargetSelection slots={}", target_slots.len()); - for (i, s) in target_slots.iter().enumerate() { - println!("ZZ_FLOW slot[{i}] optional={} n_legal={}", s.optional, s.legal_targets.len()); - } - break; - } - WaitingFor::DistributeAmong { total, targets, .. } => { - println!("ZZ_FLOW DistributeAmong total={total} n_targets={}", targets.len()); - break; - } - _ => runner.pass_both_players(), - } - } - panic!("ZZ_FLOW done"); -}