Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,8 +1002,9 @@ pub enum TargetSelectionAdvance {
/// CR 601.2c + CR 115.3: Identifies one instance of the word "target" on an
/// ability. Slots sharing a `TargetInstanceId` are the SAME "target" (all slots
/// of one `multi_target` "up to N target creatures" run) and must be mutually
/// distinct objects; slots with DIFFERENT ids are separate instances that may
/// reuse the same object ("Destroy target artifact and target land").
/// distinct objects or players; slots with DIFFERENT ids are separate instances
/// that may reuse the same object or player ("Destroy target artifact and
/// target land").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TargetInstanceId(usize);

Expand Down Expand Up @@ -5080,21 +5081,22 @@ fn legal_targets_for_selected_slot(
});
}

// CR 601.2c + CR 115.3: within one instance of "target", the same object
// can't be chosen twice. Remove objects already chosen in prior slots of
// THIS instance. Prior slots of a DIFFERENT instance (separate "target") do
// not constrain this slot — they may legally reuse the same object
// (CR 601.2c "Destroy target artifact and target land" Example).
let already_in_instance: std::collections::HashSet<ObjectId> = prior_specs
// CR 601.2c + CR 115.3: within one instance of "target", the same target —
// object OR player — can't be chosen twice. Remove targets already chosen
// in prior slots of THIS instance (issue #6459: Scheming Symmetry's
// "Choose two target players." accepted the same player for both slots
// because this set was narrowed to `ObjectId` and dropped
// `TargetRef::Player`). Prior slots of a DIFFERENT instance (separate
// "target") do not constrain this slot — they may legally reuse the same
// object or player (CR 601.2c "Destroy target artifact and target land"
// Example).
let already_in_instance: std::collections::HashSet<TargetRef> = prior_specs
.iter()
.zip(selected_slots)
.filter(|(prior, _)| prior.instance == spec.instance)
.filter_map(|(_, sel)| match sel {
Some(TargetRef::Object(id)) => Some(*id),
_ => None,
})
.filter_map(|(_, sel)| sel.clone())
.collect();
legal.retain(|t| !matches!(t, TargetRef::Object(id) if already_in_instance.contains(id)));
legal.retain(|t| !already_in_instance.contains(t));

// CR 115.4: "other target" / "another target" is a separate instance of
// "target" but must differ from every target already chosen for this
Expand Down
8 changes: 5 additions & 3 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6205,14 +6205,16 @@ impl PublicStateDirty {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TargetSelectionConstraint {
/// CR 601.2c + CR 115.3: every chosen player target must be a different
/// player. Attached by the modal path ("each mode must target a different
/// player"), where distinctness spans the ability's modes rather than one
/// instance of the word "target".
DifferentTargetPlayers,
/// CR 115.1 + CR 601.2c: Object targets must be controlled by different players.
DifferentObjectControllers,
/// CR 115.1 + CR 601.2c + CR 400.1: Object targets must come from the same
/// player-owned zone of the given kind, e.g. "from a single graveyard".
SameZoneOwner {
zone: Zone,
},
SameZoneOwner { zone: Zone },
/// CR 202.3 + CR 601.2c: the chosen target set's combined mana value must
/// satisfy `comparator` against `value`. `value` is a `QuantityExpr` (not
/// `i32` like `SearchSelectionConstraint::TotalManaValue`) because the bound
Expand Down
121 changes: 121 additions & 0 deletions crates/engine/tests/integration/issue_6459_scheming_symmetry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Scheming Symmetry — "Choose two target players." must require TWO DIFFERENT
//! players (CR 601.2c + CR 115.3: the same target — object or player — can't be
//! chosen multiple times for any one instance of the word "target").
//!
//! Regression for issue #6459: in a multiplayer game the same player could be
//! chosen for both slots. The per-instance distinctness filter in
//! `legal_targets_for_selected_slot` (`game/ability_utils.rs`) excluded
//! already-chosen OBJECTS but dropped `TargetRef::Player`, so player targets
//! within one instance of "target" were never kept distinct. The fix widens
//! that set from `HashSet<ObjectId>` to `HashSet<TargetRef>`.
//!
//! Proof is end-to-end at runtime: after the first player is chosen, choosing
//! that SAME player for the second slot is rejected (and the slot stays open),
//! while a DIFFERENT player is accepted (the discriminating behaviour).

use engine::game::scenario::GameScenario;
use engine::types::ability::TargetRef;
use engine::types::actions::GameAction;
use engine::types::game_state::{CastPaymentMode, WaitingFor};
use engine::types::mana::ManaCost;
use engine::types::phase::Phase;
use engine::types::player::PlayerId;

const P0: PlayerId = PlayerId(0);
const P1: PlayerId = PlayerId(1);
const P2: PlayerId = PlayerId(2);

const SCHEMING: &str =
"Choose two target players. Each of them searches their library for a card, \
then shuffles and puts that card on top.";

#[test]
fn scheming_symmetry_rejects_choosing_the_same_player_twice() {
let mut scenario = GameScenario::new_n_player(3, 42);
scenario.at_phase(Phase::PreCombatMain);
for &pid in &[P0, P1, P2] {
scenario.with_library_top(pid, &["Lib A", "Lib B"]);
}
let spell = scenario
.add_spell_to_hand_from_oracle(P0, "Scheming Symmetry", true, SCHEMING)
.with_mana_cost(ManaCost::zero())
.id();
let mut runner = scenario.build();

let card_id = runner.state().objects[&spell].card_id;
runner
.act(GameAction::CastSpell {
object_id: spell,
card_id,
targets: vec![],
payment_mode: CastPaymentMode::Auto,
})
.expect("casting the sorcery must be accepted");

// First slot: all three players are legal. Choose P1.
let WaitingFor::TargetSelection {
target_slots,
selection,
..
} = runner.state().waiting_for.clone()
else {
panic!(
"expected a per-slot TargetSelection, got {}",
runner.waiting_for_kind()
);
};
let slot0 = &target_slots[selection.current_slot];
for pid in [P0, P1, P2] {
assert!(
slot0.legal_targets.contains(&TargetRef::Player(pid)),
"{pid:?} must be a legal first-slot target, slot = {slot0:?}"
);
}
runner
.act(GameAction::ChooseTarget {
target: Some(TargetRef::Player(P1)),
})
.expect("choosing P1 for the first slot must succeed");

// Second slot: choosing the ALREADY-CHOSEN player P1 must be rejected
// (CR 601.2c + CR 115.3), while the state stays on the same target slot.
assert!(
matches!(
runner.state().waiting_for,
WaitingFor::TargetSelection { .. }
),
"a second target slot must be surfaced, got {}",
runner.waiting_for_kind()
);
let reselect_same = runner.act(GameAction::ChooseTarget {
target: Some(TargetRef::Player(P1)),
});
assert!(
reselect_same.is_err(),
"CR 601.2c + CR 115.3 (issue #6459): choosing the already-chosen player \
P1 for the second slot must be rejected"
);
assert!(
matches!(
runner.state().waiting_for,
WaitingFor::TargetSelection { .. }
),
"after the rejected reselection the second target slot must still be open"
);

// A DIFFERENT player (P2) is accepted, so the requirement is satisfiable —
// proving the rejection is the distinctness rule, not a dead slot.
runner
.act(GameAction::ChooseTarget {
target: Some(TargetRef::Player(P2)),
})
.expect("choosing a different player (P2) for the second slot must succeed");
assert!(
!matches!(
runner.state().waiting_for,
WaitingFor::TargetSelection { .. }
),
"with two distinct players chosen the spell must leave target selection, got {}",
runner.waiting_for_kind()
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ mod issue_6092_ability_block_reason;
mod issue_6102_ragavan_exile_cast;
mod issue_6157_gold_token_auto_mana_payment;
mod issue_629_fractured_sanity_cycling;
mod issue_6459_scheming_symmetry;
mod issue_6498_portent_of_calamity;
mod issue_6500_loreseekers_stone_hand_cost;
mod issue_654_stridehangar_automaton;
Expand Down
Loading