diff --git a/client/src/components/targeting/TargetingOverlay.tsx b/client/src/components/targeting/TargetingOverlay.tsx index 1508f2fee..8d81cc94e 100644 --- a/client/src/components/targeting/TargetingOverlay.tsx +++ b/client/src/components/targeting/TargetingOverlay.tsx @@ -314,10 +314,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 37bd30539..8bfe8a8f3 100644 --- a/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx +++ b/client/src/components/targeting/__tests__/TargetingOverlay.test.tsx @@ -563,4 +563,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/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 000000000..8b4dd567a --- /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 72fba8e6f..d0573d201 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -271,6 +271,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;