Skip to content
Merged
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
5 changes: 5 additions & 0 deletions crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,11 @@ fn condition_reads_only_memo_safe_state(c: &ParsedCondition) -> bool {
| ParsedCondition::CardsLeftYourGraveyardThisTurnAtLeast { .. }
| ParsedCondition::PlayerCountAtLeast { .. }
| ParsedCondition::HasCityBlessing
// CR 503.1: reads only `state.phase`, apply()-constant global state.
| ParsedCondition::IsDuringUpkeep
// CR 102.2 / CR 102.3: reads `state.active_player` plus team topology,
// both apply()-constant like `IsYourTurn`.
| ParsedCondition::IsOpponentsTurn
| ParsedCondition::IsYourTurn => true,
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1987,6 +1987,7 @@ fn legacy_static_condition(x: &StaticCondition) -> bool {
| StaticCondition::CompletedADungeon
| StaticCondition::Unrecognized { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::WasCast { .. }
| StaticCondition::IsRingBearer
| StaticCondition::RingLevelAtLeast { .. }
Expand Down Expand Up @@ -6315,6 +6316,7 @@ fn rw_static_condition(x: &StaticCondition) -> RwProfile {
| StaticCondition::CompletedADungeon
| StaticCondition::Unrecognized { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::WasCast { .. }
| StaticCondition::IsRingBearer
| StaticCondition::RingLevelAtLeast { .. }
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3563,6 +3563,7 @@ fn scan_static_condition(x: &StaticCondition, mode: ScanMode) -> Axes {
StaticCondition::UnlessPay { .. } => Axes::CONSERVATIVE,
StaticCondition::Unrecognized { text: _ } => Axes::NONE,
StaticCondition::DuringYourTurn => Axes::NONE,
StaticCondition::DuringOpponentsTurn => Axes::NONE,
StaticCondition::SharesColorWithMostCommonColorAmongPermanents => Axes::NONE,
StaticCondition::SourceEnteredThisTurn => Axes {
event: false,
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7270,7 +7270,7 @@ fn evaluate_cost_mod_static_condition(
use crate::types::ability::StaticCondition;

match condition {
StaticCondition::DuringYourTurn => {
StaticCondition::DuringYourTurn | StaticCondition::DuringOpponentsTurn => {
super::layers::evaluate_condition(state, condition, source_controller, source_id)
}
StaticCondition::And { conditions } => conditions.iter().all(|c| {
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4202,6 +4202,7 @@ fn fmt_static_condition(cond: &StaticCondition) -> String {
SC::UnlessPay { .. } => "unless a cost is paid".into(),
SC::Unrecognized { .. } => "unrecognized".into(),
SC::DuringYourTurn => "during your turn".into(),
SC::DuringOpponentsTurn => "during an opponent's turn".into(),
SC::SharesColorWithMostCommonColorAmongPermanents => {
"shares a color with the most common color among all permanents".into()
}
Expand Down Expand Up @@ -7755,6 +7756,7 @@ fn static_condition_feature(cond: &StaticCondition) -> (&'static str, FeatureSup
}
StaticCondition::ClassLevelGE { .. } => ("ClassLevelGE", Handled),
StaticCondition::DuringYourTurn => ("DuringYourTurn", Handled),
StaticCondition::DuringOpponentsTurn => ("DuringOpponentsTurn", Handled),
StaticCondition::DayNightIs { .. } => ("DayNightIs", Handled),
StaticCondition::SharesColorWithMostCommonColorAmongPermanents => {
("SharesColorWithMostCommonColorAmongPermanents", Handled)
Expand Down
13 changes: 13 additions & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,7 @@ fn static_condition_uses_object_population(condition: &StaticCondition) -> bool
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::SourceEnteredThisTurn
| StaticCondition::SourceHasDealtDamage
| StaticCondition::WasCast { .. }
Expand Down Expand Up @@ -1231,6 +1232,7 @@ fn entered_object_perturbs_static_condition(
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::SourceEnteredThisTurn
| StaticCondition::SourceHasDealtDamage
| StaticCondition::WasCast { .. }
Expand Down Expand Up @@ -1440,6 +1442,16 @@ fn evaluate_condition_with_context(
.unwrap_or(controller);
state.active_player == source_controller
}
// CR 102.3 + CR 805.4a: team-aware opponent relation. A teammate
// holding `active_player` does not make this an opponent's turn.
StaticCondition::DuringOpponentsTurn => {
let source_controller = state
.objects
.get(&source_id)
.map(|obj| obj.controller)
.unwrap_or(controller);
super::players::is_opponent(state, source_controller, state.active_player)
}
// CR 103.1: True when the scoped player took the first turn of the
// game (fixed at game start). The parser emits `ControllerRef::You`.
StaticCondition::WasStartingPlayer { .. } => state.current_starting_player == controller,
Expand Down Expand Up @@ -3266,6 +3278,7 @@ fn static_condition_reads_life(condition: &StaticCondition) -> bool {
| StaticCondition::UnlessPay { .. }
| StaticCondition::Unrecognized { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::SharesColorWithMostCommonColorAmongPermanents
| StaticCondition::SourceEnteredThisTurn
| StaticCondition::SourceHasDealtDamage
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,7 @@ pub(crate) fn static_condition_uses_unspent_mana(condition: &StaticCondition) ->
| StaticCondition::UnlessPay { .. }
| StaticCondition::Unrecognized { .. }
| StaticCondition::DuringYourTurn
| StaticCondition::DuringOpponentsTurn
| StaticCondition::SourceEnteredThisTurn
| StaticCondition::SourceHasDealtDamage
| StaticCondition::WasCast { .. }
Expand Down
162 changes: 159 additions & 3 deletions crates/engine/src/game/restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,20 +1163,28 @@ fn casting_restriction_applies(
// CR 307.1: A player may cast a sorcery during a main phase of their turn when the stack is empty.
CastingRestriction::AsSorcery => is_sorcery_speed_window(state, player),
CastingRestriction::DuringCombat => state.phase.is_combat(),
CastingRestriction::DuringOpponentsTurn => state.active_player != player,
// CR 102.3 / CR 805.4a: "an opponent's turn" is a team-aware relation.
// Under shared team turns a turn where a teammate holds `active_player`
// still belongs to the caster's own team, so `active_player != player`
// over-permits. Same authority as `ParsedCondition::IsOpponentsTurn`.
CastingRestriction::DuringOpponentsTurn => {
super::players::is_opponent(state, player, state.active_player)
}
CastingRestriction::DuringYourTurn => state.active_player == player,
CastingRestriction::DuringYourUpkeep => {
state.active_player == player && state.phase == Phase::Upkeep
}
CastingRestriction::DuringOpponentsUpkeep => {
state.active_player != player && state.phase == Phase::Upkeep
super::players::is_opponent(state, player, state.active_player)
&& state.phase == Phase::Upkeep
}
CastingRestriction::DuringAnyUpkeep => state.phase == Phase::Upkeep,
CastingRestriction::DuringYourEndStep => {
state.active_player == player && state.phase == Phase::End
}
CastingRestriction::DuringOpponentsEndStep => {
state.active_player != player && state.phase == Phase::End
super::players::is_opponent(state, player, state.active_player)
&& state.phase == Phase::End
}
// CR 508.1: Declare attackers step.
CastingRestriction::DeclareAttackersStep => state.phase == Phase::DeclareAttackers,
Expand Down Expand Up @@ -1629,6 +1637,17 @@ pub(crate) fn evaluate_condition(
ParsedCondition::HasCityBlessing => state.city_blessing.contains(&player),
// CR 102.1: "The active player is the player whose turn it is."
ParsedCondition::IsYourTurn => state.active_player == player,
// CR 102.3 / CR 805.4a: the active player is on a team other than
// `player`'s. Delegates to the single team-aware authority, so a turn
// where a TEAMMATE holds `active_player` (CR 805.4 shared team turns —
// the active team is still `player`'s own team) is NOT reported as an
// opponent's turn, which `active_player != player` would do.
ParsedCondition::IsOpponentsTurn => {
super::players::is_opponent(state, player, state.active_player)
}
// CR 503.1: The game is currently in the upkeep step. Player scope, if
// any, is composed by the caller via `And([IsOpponentsTurn, ..])`.
ParsedCondition::IsDuringUpkeep => state.phase == Phase::Upkeep,
// CR 601.3d + CR 608.2c: "if it targets a [filter]" — gates a casting
// permission on the chosen targets of the in-flight spell. Read from
// `state.pending_cast.ability.targets` when targets have been committed.
Expand Down Expand Up @@ -3439,6 +3458,143 @@ mod tests {
));
}

/// Trade Caravan's activated ability, as the Oracle parser actually emits
/// it. The gate under test is the PARSED restriction, not a hand-built
/// one, so a parser regression fails these cases too.
fn trade_caravan_activation_restrictions() -> Vec<ActivationRestriction> {
let parsed = crate::parser::oracle::parse_oracle_text(
"Remove two currency counters from ~: Untap target basic land. \
Activate only during an opponent's upkeep.",
"Trade Caravan",
&[],
&["Creature".to_string()],
&["Human".to_string(), "Nomad".to_string()],
);
assert_eq!(parsed.abilities.len(), 1, "got {:#?}", parsed.abilities);
parsed.abilities[0].activation_restrictions.clone()
}

/// CR 602.5b + CR 102.3 + CR 503.1 + CR 805.4a: "Activate only during an
/// opponent's upkeep" must gate real activation legality, so this drives the
/// production entry point `check_activation_restrictions` (which reaches
/// `activation_restriction_applies`) rather than `evaluate_condition`
/// directly, across the full turn-scope × step matrix.
#[test]
fn opponents_upkeep_activation_gate_allows_only_opponent_upkeep() {
let restrictions = trade_caravan_activation_restrictions();
let mut state = crate::types::game_state::GameState::new_two_player(42);
let activator = PlayerId(0);
let allowed = |state: &crate::types::game_state::GameState| {
check_activation_restrictions(state, activator, ObjectId(10), 0, &restrictions).is_ok()
};

// Opponent's turn, upkeep step -> activation permitted.
state.active_player = PlayerId(1);
state.phase = Phase::Upkeep;
assert!(allowed(&state), "opponent's upkeep must permit activation");

// Opponent's turn, non-upkeep step -> denied (IsDuringUpkeep false).
state.phase = Phase::PreCombatMain;
assert!(
!allowed(&state),
"opponent's main phase must deny activation"
);

// Your own upkeep -> denied (IsOpponentsTurn false).
state.active_player = PlayerId(0);
state.phase = Phase::Upkeep;
assert!(!allowed(&state), "your own upkeep must deny activation");
}

/// CR 102.3 + CR 805.4 + CR 810.2: under shared team turns the turn belongs
/// to a TEAM, so an upkeep in which a teammate holds `active_player` is the
/// activator's OWN team's upkeep and must not open the window. This is
/// exactly what the weaker `Not(IsYourTurn)` encoding got wrong — the
/// teammate is not the activator, so "not your turn" held and the ability
/// became activatable during the activator's own team's upkeep.
#[test]
fn opponents_upkeep_activation_gate_denies_own_team_upkeep_in_two_headed_giant() {
use crate::types::format::FormatConfig;

let restrictions = trade_caravan_activation_restrictions();
// Seats 0/1 are one team, seats 2/3 the other.
let mut state =
crate::types::game_state::GameState::new(FormatConfig::two_headed_giant(), 4, 42);
let activator = PlayerId(0);
state.phase = Phase::Upkeep;
let allowed = |state: &crate::types::game_state::GameState| {
check_activation_restrictions(state, activator, ObjectId(10), 0, &restrictions).is_ok()
};

// Teammate holds `active_player` -> still the activator's own team's
// upkeep (CR 805.4a), so activation is denied. This is the regression:
// `Not(IsYourTurn)` would have permitted it.
state.active_player = PlayerId(1);
assert!(
!allowed(&state),
"a teammate's upkeep is the activator's own team's upkeep, not an opponent's"
);

// Opposing team's upkeep -> permitted.
state.active_player = PlayerId(2);
assert!(
allowed(&state),
"an opposing team's upkeep must permit activation"
);

// Activator holds `active_player` -> denied.
state.active_player = PlayerId(0);
assert!(!allowed(&state), "your own upkeep must deny activation");
}

/// CR 102.3 + CR 805.4a: every opponent-scoped casting restriction uses
/// the same team-aware relation as the parsed activation condition. A
/// teammate holding `active_player` is not an opponent, including in the
/// upkeep and end-step siblings of the whole-turn restriction.
#[test]
fn opponent_scoped_casting_restrictions_exclude_teammate_turns() {
use crate::types::format::FormatConfig;

let mut state =
crate::types::game_state::GameState::new(FormatConfig::two_headed_giant(), 4, 42);
let caster = PlayerId(0);
let source = ObjectId(10);

for (restriction, phase) in [
(
CastingRestriction::DuringOpponentsTurn,
Phase::PreCombatMain,
),
(CastingRestriction::DuringOpponentsUpkeep, Phase::Upkeep),
(CastingRestriction::DuringOpponentsEndStep, Phase::End),
] {
state.phase = phase;
state.active_player = PlayerId(1);
assert!(
check_casting_restrictions(
&state,
caster,
source,
std::slice::from_ref(&restriction),
)
.is_err(),
"a teammate's {phase:?} must not satisfy {restriction:?}"
);

state.active_player = PlayerId(2);
assert!(
check_casting_restrictions(
&state,
caster,
source,
std::slice::from_ref(&restriction),
)
.is_ok(),
"an opposing team's {phase:?} must satisfy the restriction"
);
}
}

#[test]
fn evaluates_creatures_you_control_total_power_condition() {
let mut state = crate::types::game_state::GameState::new_two_player(42);
Expand Down
Loading
Loading