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;