diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index f3b72621c6..4017b27770 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -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, } } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 246333bc7a..a9000b81d2 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -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 { .. } @@ -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 { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 31493a5a79..86f372faed 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -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, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 463390e53b..f52de336c3 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -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| { diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index d77487eb86..8fe3233585 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -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() } @@ -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) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 5441d1abc9..185747d46a 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -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 { .. } @@ -1231,6 +1232,7 @@ fn entered_object_perturbs_static_condition( | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn + | StaticCondition::DuringOpponentsTurn | StaticCondition::SourceEnteredThisTurn | StaticCondition::SourceHasDealtDamage | StaticCondition::WasCast { .. } @@ -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, @@ -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 diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 2e8362de5b..8bc7593492 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -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 { .. } diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index bc74be690f..a1e376e643 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -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, @@ -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. @@ -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 { + 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); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 0e6f01058d..e64cecc417 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -8681,7 +8681,14 @@ fn check_trigger_constraint_with_ref( definition_ref.is_none_or(|key| !state.triggers_fired_this_game.contains(key)) } TriggerConstraint::OnlyDuringYourTurn => state.active_player == controller, - TriggerConstraint::OnlyDuringOpponentsTurn => state.active_player != controller, + // CR 102.3 / CR 805.4a: team-aware — under shared team turns a turn + // where a teammate holds `active_player` still belongs to the + // controller's own team, so the weaker `active_player != controller` + // test would over-fire. Same authority as + // `ParsedCondition::IsOpponentsTurn`. + TriggerConstraint::OnlyDuringOpponentsTurn => { + super::players::is_opponent(state, controller, state.active_player) + } TriggerConstraint::OncePerOpponentPerTurn => { // CR 603.2: The trigger event only matches the first life-loss event // during that opponent's own turn. @@ -9568,9 +9575,12 @@ fn evaluate_trigger_condition_with_source( TriggerCondition::DuringPlayersTurn { player } => match player { // CR 102.1: "your turn" — controller is active. PlayerFilter::Controller => state.active_player == controller, - // CR 102.1 + CR 102.2: "an opponent's turn" — active player is any - // non-controller (set-valued match: true whenever it isn't your turn). - PlayerFilter::Opponent => state.active_player != controller, + // CR 102.3 + CR 805.4a: "an opponent's turn" is team-aware. Under + // shared team turns, a teammate holding `active_player` remains on + // the controller's active team and is not an opponent. + PlayerFilter::Opponent => { + super::players::is_opponent(state, controller, state.active_player) + } // CR 603.4 + CR 102.1: "that player's turn" — the player named by // the trigger event (drawer / tapper / damaged player / etc.) is // currently the active player. @@ -12131,6 +12141,40 @@ pub mod tests { TriggerDefinition::new(mode) } + /// CR 102.3 + CR 805.4a: an opponent-turn trigger constraint must read the + /// active player's team relation, not merely whether that player is the + /// trigger controller. This drives the production constraint gate used by + /// trigger collection rather than the relation helper directly. + #[test] + fn opponents_turn_trigger_constraint_excludes_teammate_in_two_headed_giant() { + let mut state = GameState::new( + crate::types::format::FormatConfig::two_headed_giant(), + 4, + 42, + ); + let mut trigger = make_trigger(TriggerMode::Drawn); + trigger.constraint = Some(TriggerConstraint::OnlyDuringOpponentsTurn); + let event = GameEvent::CardDrawn { + player_id: PlayerId(2), + object_id: ObjectId(99), + nth_in_turn: 1, + nth_in_step: 1, + }; + + // Seats 0/1 share a team: teammate 1 is not an opponent of controller 0. + state.active_player = PlayerId(1); + assert!( + !check_trigger_constraint(&state, &trigger, ObjectId(1), 0, PlayerId(0), &event), + "a teammate's turn must not satisfy OnlyDuringOpponentsTurn" + ); + + state.active_player = PlayerId(2); + assert!( + check_trigger_constraint(&state, &trigger, ObjectId(1), 0, PlayerId(0), &event), + "an opposing team's turn must satisfy OnlyDuringOpponentsTurn" + ); + } + /// Regression (issue #770 cluster — Sheoldred/Replicating Ring/Skyline /// Despot/Bitterbloom/Braids upkeep triggers silently not firing): /// `process_collected_triggers_with_delayed_phase_events` must not @@ -27422,12 +27466,16 @@ pub mod tests { )); } - /// CR 603.4 + CR 102.1 + CR 102.2 — `DuringPlayersTurn { Opponent }` - /// preserves the pre-refactor semantics of the retired `DuringOpponentsTurn` - /// variant: true iff the active player is NOT the trigger controller. + /// CR 603.4 + CR 102.3 + CR 805.4a — `DuringPlayersTurn { Opponent }` + /// is true only while the active player is on an opposing team, not merely + /// while a non-controller seat is active. #[test] - fn during_players_turn_opponent_tracks_active_not_controller() { - let mut state = setup(); + fn during_players_turn_opponent_tracks_team_aware_active_relation() { + let mut state = GameState::new( + crate::types::format::FormatConfig::two_headed_giant(), + 4, + 42, + ); let controller = PlayerId(0); let condition = TriggerCondition::DuringPlayersTurn { player: PlayerFilter::Opponent, @@ -27438,7 +27486,14 @@ pub mod tests { &state, &condition, controller, None, None )); + // Seats 0/1 are teammates, so a teammate's shared-team turn is not an + // opponent's turn. state.active_player = PlayerId(1); + assert!(!check_trigger_condition( + &state, &condition, controller, None, None + )); + + state.active_player = PlayerId(2); assert!(check_trigger_condition( &state, &condition, controller, None, None )); diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 58edabd9c8..11b923ff7f 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -7644,9 +7644,16 @@ fn find_top_level_colon(line: &str) -> Option { /// decline rather than mis-classify. /// The single-gate `during`-role / speed sub-combinator, factored out so it can /// be the first half of a compound "X and only Y" / "X, Y" activation-timing -/// gate. Each arm emits an EXISTING enforced `ActivationRestriction` value — -/// the opponent-turn arm reuses `opponents_turn_activation_restriction()` -/// (= `RequiresCondition{Not(IsYourTurn)}`), NOT a new variant. +/// gate. Every arm emits an EXISTING `ActivationRestriction` variant — the +/// opponent-scoped arms express their scope as a `ParsedCondition` under the +/// existing `RequiresCondition`, so no `DuringOpponents*` restriction sibling +/// is introduced. +/// +/// The `during ...` half is nested prefix dispatch rather than a flat list of +/// whole-clause tags: the shared `"during "` prefix is matched once, then the +/// turn-role and turn-window axes are consumed by their own sub-combinators, so +/// the four role×window gates come from four small tags instead of eight +/// enumerated phrases (and a new spelling on either axis is a one-tag change). fn parse_activation_during_role_gate(i: &str) -> OracleResult<'_, ActivationRestriction> { alt(( value( @@ -7654,21 +7661,7 @@ fn parse_activation_during_role_gate(i: &str) -> OracleResult<'_, ActivationRest tag::<_, _, OracleError<'_>>("as a sorcery"), ), value(ActivationRestriction::AsInstant, tag("as an instant")), - value( - opponents_turn_activation_restriction(), - alt(( - tag("during an opponent's turn"), - tag("during an opponents turn"), - )), - ), - value( - ActivationRestriction::DuringYourTurn, - alt((tag("during your turn"), tag("during their turn"))), - ), - value( - ActivationRestriction::DuringYourUpkeep, - alt((tag("during your upkeep"), tag("during their upkeep"))), - ), + parse_activation_during_gate, )) .parse(i) } @@ -7717,8 +7710,9 @@ fn parse_activation_timing_restriction(phrase: &str) -> Option [and only | , ] before combat/attackers" // activation-timing gate — turn-role half reuses - // RequiresCondition{Not(IsYourTurn)} / DuringYourTurn, combat-window half - // reuses BeforeAttackersDeclared. Composed with a trailing + // RequiresCondition{IsOpponentsTurn} / DuringYourTurn (CR 102.3 + + // CR 805.4a), combat-window half reuses BeforeAttackersDeclared. + // Composed with a trailing // `opt(pair(separator, before-window))`, no permutation enumeration and no // `contains`/`split_once` dispatch. Preserves the single-gate behavior // above (a bare "during an opponent's turn" still returns one restriction). @@ -7869,17 +7863,123 @@ fn preserve_activation_timing_parenthetical(raw_line: &str) -> Option { Some(format!("{prefix} {timing_text}.")) } +/// CR 102.1 + CR 102.3: whose turn an activation-timing gate scopes to. The two +/// roles are NOT complements — under shared team turns (CR 805.4) "your turn" +/// is a seat question and "an opponent's turn" is a team question — so each +/// lowers to its own predicate rather than one negated flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActivationTurnRole { + /// "your " / "their " — the activating player's own turn. + Yours, + /// "an opponent's " / "an opponents " — a turn of a player on another team. + Opponents, +} + +/// CR 500.1 + CR 503.1: which window of the scoped turn the gate admits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActivationTurnWindow { + /// "turn" — the whole turn, any step or phase. + WholeTurn, + /// "upkeep" — the upkeep step only. + Upkeep, +} + +/// The turn-role axis of a `during ...` activation gate. Both possessive +/// spellings of the opponent role are accepted (Oracle text and the misparse +/// corpus both occur with and without the apostrophe). +fn parse_activation_turn_role(i: &str) -> OracleResult<'_, ActivationTurnRole> { + alt(( + value( + ActivationTurnRole::Opponents, + alt(( + tag::<_, _, OracleError<'_>>("an opponent's "), + tag("an opponents "), + )), + ), + value( + ActivationTurnRole::Yours, + // "their" is the activating player's possessive — equivalent to + // "your" once an activator is fixed. + alt((tag("your "), tag("their "))), + ), + )) + .parse(i) +} + +/// The turn-window axis of a `during ...` activation gate. +fn parse_activation_turn_window(i: &str) -> OracleResult<'_, ActivationTurnWindow> { + alt(( + value( + ActivationTurnWindow::Upkeep, + tag::<_, _, OracleError<'_>>("upkeep"), + ), + value(ActivationTurnWindow::WholeTurn, tag("turn")), + )) + .parse(i) +} + +/// CR 602.5b: the composed `during ` activation gate — the +/// shared prefix is consumed once, then each axis by its own sub-combinator. +fn parse_activation_during_gate(i: &str) -> OracleResult<'_, ActivationRestriction> { + let (rest, (role, window)) = preceded( + tag::<_, _, OracleError<'_>>("during "), + (parse_activation_turn_role, parse_activation_turn_window), + ) + .parse(i)?; + Ok((rest, activation_turn_gate(role, window))) +} + +/// CR 602.5b: map a (role, window) pair onto an EXISTING enforced +/// `ActivationRestriction`. No arm introduces a new variant — the opponent +/// arms compose `ParsedCondition` leaves under `RequiresCondition`. +fn activation_turn_gate( + role: ActivationTurnRole, + window: ActivationTurnWindow, +) -> ActivationRestriction { + match (role, window) { + (ActivationTurnRole::Yours, ActivationTurnWindow::WholeTurn) => { + ActivationRestriction::DuringYourTurn + } + (ActivationTurnRole::Yours, ActivationTurnWindow::Upkeep) => { + ActivationRestriction::DuringYourUpkeep + } + (ActivationTurnRole::Opponents, ActivationTurnWindow::WholeTurn) => { + opponents_turn_activation_restriction() + } + (ActivationTurnRole::Opponents, ActivationTurnWindow::Upkeep) => { + opponents_upkeep_activation_restriction() + } + } +} + fn opponents_turn_activation_restriction() -> ActivationRestriction { ActivationRestriction::RequiresCondition { condition: Some(opponents_turn_activation_condition()), } } -/// CR 602.5b + CR 102.1 + CR 109.5: "Activate only during an opponent's turn" -/// gates activation to turns where the activator is not the active player. +/// CR 602.5b + CR 102.3 + CR 805.4a: "Activate only during an opponent's turn" +/// gates activation to turns belonging to an opposing TEAM. Not +/// `Not(IsYourTurn)`: under shared team turns that also admits a turn where a +/// teammate holds `active_player`, which is the activator's own team's turn. fn opponents_turn_activation_condition() -> ParsedCondition { - ParsedCondition::Not { - condition: Box::new(ParsedCondition::IsYourTurn), + ParsedCondition::IsOpponentsTurn +} + +/// CR 602.5b + CR 102.3 + CR 503.1: "Activate only during an opponent's upkeep" +/// gates activation to the upkeep step of an opponent's turn (Trade Caravan). +/// Composed from the same team-aware opponent-turn leaf as +/// `opponents_turn_activation_condition` plus the `IsDuringUpkeep` step +/// predicate, so the opponent scope reuses the existing composition idiom +/// instead of a dedicated `DuringOpponents*` restriction sibling per step. +fn opponents_upkeep_activation_restriction() -> ActivationRestriction { + ActivationRestriction::RequiresCondition { + condition: Some(ParsedCondition::And { + conditions: vec![ + opponents_turn_activation_condition(), + ParsedCondition::IsDuringUpkeep, + ], + }), } } diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index 42a88c08c7..cd99f7cade 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -274,6 +274,9 @@ fn static_condition_to_restriction_condition( // The `Not` recursion arm above yields `Not(IsYourTurn)` for // "it's not your turn". StaticCondition::DuringYourTurn => Some(ParsedCondition::IsYourTurn), + // CR 102.3 + CR 805.4a: keep the opponent relation distinct from + // `Not(IsYourTurn)`, which would incorrectly include a teammate's turn. + StaticCondition::DuringOpponentsTurn => Some(ParsedCondition::IsOpponentsTurn), // CR 903.3d: "If an effect refers to controlling a commander, it refers to a // permanent on the battlefield that is a commander" — regardless of who OWNS it. // That is exactly an `ObjectCount` over the `IsCommander` filter scoped to your diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index a56212c6d8..a8d40deb4c 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4816,6 +4816,10 @@ pub(crate) fn static_condition_to_ability_condition( // predicate (Layer 6 / duration `ForAsLongAs`); no effect-resolution // (`AbilityCondition`) equivalent — lowering returns `None`. | StaticCondition::TopOfLibraryMatches { .. } + // CR 102.3 + CR 805.4a: the team-aware opponent-turn predicate has + // no `AbilityCondition` counterpart yet. Return `None` rather than + // lowering it to `Not(IsYourTurn)`, which would be wrong in 2HG. + | StaticCondition::DuringOpponentsTurn | StaticCondition::None => None, } } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 2adffab2f0..81cff9b545 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -1187,9 +1187,9 @@ fn parse_turn_conditions(input: &str) -> OracleResult<'_, StaticCondition> { map(tag("it's not your turn"), |_| StaticCondition::Not { condition: Box::new(StaticCondition::DuringYourTurn), }), - // CR 102.1 + CR 102.2: there is always exactly one active player, so - // "it's an opponent's turn" is exactly "it's not your turn" — the active - // player is any non-controller. Maps to the same Not(DuringYourTurn). + // CR 102.3 + CR 805.4a: "it's an opponent's turn" names an opponent + // relation, not merely a non-controller active seat. In team games a + // teammate can be active while the controller's team still has the turn. // Both apostrophe forms are accepted at each position (U+0027 straight // and U+2019 curly — Scryfall English oracle text uses the curly form). // The surface permutations are composed from two small `alt`s rather than @@ -1201,9 +1201,7 @@ fn parse_turn_conditions(input: &str) -> OracleResult<'_, StaticCondition> { alt((tag("'s"), tag("\u{2019}s"))), tag(" turn"), ), - |_| StaticCondition::Not { - condition: Box::new(StaticCondition::DuringYourTurn), - }, + |_| StaticCondition::DuringOpponentsTurn, ), parse_day_night_condition, )) @@ -9814,15 +9812,11 @@ mod tests { #[test] fn test_parse_condition_opponents_turn() { - // CR 102.1 + CR 102.2: there is always exactly one active player, so - // "it's an opponent's turn" is exactly "it's not your turn" — the active - // player is any non-controller. Represented as `Not(DuringYourTurn)`, - // mirroring the existing "it's not your turn" arm. Both apostrophe forms - // (U+0027 straight, U+2019 curly — Scryfall uses the curly form) parse at - // each position, so the contraction/possessive permutations all hold. - let expected = StaticCondition::Not { - condition: Box::new(StaticCondition::DuringYourTurn), - }; + // CR 102.3 + CR 805.4a: an opponent's turn excludes the active + // controller's teammate in a team game. Both apostrophe forms (U+0027 + // straight, U+2019 curly — Scryfall uses the curly form) parse at each + // position, so the contraction/possessive permutations all hold. + let expected = StaticCondition::DuringOpponentsTurn; for input in [ "if it's an opponent's turn, do", "if it is an opponent's turn, do", diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 9078670d5b..ee92d09bdb 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -598,19 +598,23 @@ fn ability_word_labeled_activated_ability_parses_cost_effect_restriction() { ); } -fn has_not_your_turn_activation_restriction(restrictions: &[ActivationRestriction]) -> bool { +/// CR 102.3 + CR 805.4a: the opponent-turn gate is the team-aware +/// `IsOpponentsTurn` leaf, NOT `Not(IsYourTurn)` — the latter also admits a +/// turn where a teammate holds `active_player`, which under shared team turns +/// is the activator's own team's turn. +fn has_opponents_turn_activation_restriction(restrictions: &[ActivationRestriction]) -> bool { restrictions.iter().any(|restriction| { matches!( restriction, ActivationRestriction::RequiresCondition { - condition: Some(ParsedCondition::Not { condition }) - } if matches!(condition.as_ref(), ParsedCondition::IsYourTurn) + condition: Some(ParsedCondition::IsOpponentsTurn) + } ) }) } #[test] -fn activated_ability_opponent_turn_restriction_uses_not_your_turn_condition() { +fn activated_ability_opponent_turn_restriction_uses_team_aware_condition() { let r = parse( "{T}: Add {C}{C}. Activate only during an opponent's turn.", "Lavinia, Foil to Conspiracy", @@ -622,12 +626,102 @@ fn activated_ability_opponent_turn_restriction_uses_not_your_turn_condition() { assert_eq!(r.abilities.len(), 1, "got {:#?}", r.abilities); let restrictions = &r.abilities[0].activation_restrictions; assert!( - has_not_your_turn_activation_restriction(restrictions), - "expected Not(IsYourTurn) activation restriction, got {:?}", + has_opponents_turn_activation_restriction(restrictions), + "expected team-aware IsOpponentsTurn activation restriction, got {:?}", restrictions ); } +/// CR 602.5b + CR 102.3 + CR 503.1: "Activate only during an opponent's upkeep" +/// (Trade Caravan) composes the team-aware opponent-turn scope +/// (`IsOpponentsTurn`) with the upkeep-step predicate (`IsDuringUpkeep`) in a +/// single `RequiresCondition`, reusing the same composition idiom as the bare +/// opponent-turn gate above rather than a dedicated `DuringOpponents*` +/// restriction sibling. Before the fix the tail dropped to +/// `Effect::Unimplemented` and the ability was activatable at any time. +#[test] +fn activated_ability_opponents_upkeep_restriction_composes_scope_and_step() { + let r = parse( + "Remove two currency counters from ~: Untap target basic land. \ + Activate only during an opponent's upkeep.", + "Trade Caravan", + &[], + &["Creature"], + &["Human", "Nomad"], + ); + + assert_eq!(r.abilities.len(), 1, "got {:#?}", r.abilities); + let ability = &r.abilities[0]; + assert!( + !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) + && ability + .sub_ability + .as_ref() + .is_none_or(|sub| !matches!(sub.effect.as_ref(), Effect::Unimplemented { .. })), + "expected no unimplemented fallback, got {:#?}", + ability + ); + + let expected = ActivationRestriction::RequiresCondition { + condition: Some(ParsedCondition::And { + conditions: vec![ + ParsedCondition::IsOpponentsTurn, + ParsedCondition::IsDuringUpkeep, + ], + }), + }; + assert!( + ability.activation_restrictions.contains(&expected), + "expected composed opponent's-upkeep restriction, got {:?}", + ability.activation_restrictions + ); +} + +/// CR 602.5b: the `during ` activation gate is composed from a +/// turn-role axis and a turn-window axis, so every role×window pairing resolves +/// without a whole-clause tag per phrase. Both opponent possessive spellings and +/// the "their" possessive of the own-turn role are covered on each axis. +#[test] +fn activation_during_gate_composes_turn_role_and_window_axes() { + let opponents_turn = ActivationRestriction::RequiresCondition { + condition: Some(ParsedCondition::IsOpponentsTurn), + }; + let opponents_upkeep = ActivationRestriction::RequiresCondition { + condition: Some(ParsedCondition::And { + conditions: vec![ + ParsedCondition::IsOpponentsTurn, + ParsedCondition::IsDuringUpkeep, + ], + }), + }; + + for (phrase, expected) in [ + ("during your turn", ActivationRestriction::DuringYourTurn), + ("during their turn", ActivationRestriction::DuringYourTurn), + ( + "during your upkeep", + ActivationRestriction::DuringYourUpkeep, + ), + ( + "during their upkeep", + ActivationRestriction::DuringYourUpkeep, + ), + ("during an opponent's turn", opponents_turn.clone()), + ("during an opponents turn", opponents_turn.clone()), + ("during an opponent's upkeep", opponents_upkeep.clone()), + ("during an opponents upkeep", opponents_upkeep.clone()), + ] { + let line = format!("{{T}}: Add {{C}}. Activate only {phrase}."); + let r = parse(&line, "Axis Probe", &[], &["Creature"], &["Human"]); + assert_eq!(r.abilities.len(), 1, "{phrase}: got {:#?}", r.abilities); + assert!( + r.abilities[0].activation_restrictions.contains(&expected), + "{phrase}: expected {expected:?}, got {:?}", + r.abilities[0].activation_restrictions + ); + } +} + /// CR 508.1: a STANDALONE combat-window activation gate — "Activate only before /// attackers are declared" / "Activate only before combat" (Arcum's Whistle, /// Arcum's Sleigh) with no "during " first half — must map to the enforced @@ -8680,8 +8774,8 @@ fn any_player_may_activate_but_only_records_timing_restriction() { assert_eq!(activation.activator_filter, Some(PlayerFilter::All)); let restrictions = &activation.activation_restrictions; assert!( - has_not_your_turn_activation_restriction(restrictions), - "expected Not(IsYourTurn), got {:?}", + has_opponents_turn_activation_restriction(restrictions), + "expected IsOpponentsTurn, got {:?}", restrictions ); @@ -8757,8 +8851,8 @@ fn opponents_may_activate_but_only_records_timing_restriction() { ); assert_eq!(opponent_turn.activator_filter, Some(PlayerFilter::Opponent)); assert!( - has_not_your_turn_activation_restriction(&opponent_turn.activation_restrictions), - "expected Not(IsYourTurn), got {:?}", + has_opponents_turn_activation_restriction(&opponent_turn.activation_restrictions), + "expected IsOpponentsTurn, got {:?}", opponent_turn.activation_restrictions ); } diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 7da417976d..5823eacede 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -4291,6 +4291,9 @@ pub(crate) fn static_condition_to_trigger_condition( StaticCondition::DuringYourTurn => Some(TriggerCondition::DuringPlayersTurn { player: PlayerFilter::Controller, }), + StaticCondition::DuringOpponentsTurn => Some(TriggerCondition::DuringPlayersTurn { + player: PlayerFilter::Opponent, + }), StaticCondition::DayNightIs { .. } => None, StaticCondition::SharesColorWithMostCommonColorAmongPermanents => None, diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 4f5b397fd4..dfa298d0c5 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -2177,31 +2177,22 @@ fn trigger_intervening_if_negated_cast_from_hand_chainer() { } /// Discordant Spirit: "if it's an opponent's turn" must hoist as the -/// intervening-if condition. CR 102.1 + CR 102.2: a turn is never vacant, so -/// "an opponent's turn" is "the active player is any non-controller" — -/// `Not(DuringPlayersTurn { Controller })`, equivalent to "it's not your -/// turn". Without this the condition was silently dropped and the counter -/// would be placed on the controller's own end step too. +/// intervening-if condition. CR 102.3 + CR 805.4a: an opponent's turn is a +/// team-aware opponent relation, not merely a non-controller active seat. +/// Without this the condition was silently dropped and the counter would be +/// placed on the controller's own end step too. #[test] fn trigger_intervening_if_opponents_turn_discordant_spirit() { let def = parse_trigger_line( "At the beginning of each end step, if it's an opponent's turn, put a +1/+1 counter on this creature for each 1 damage dealt to you this turn.", "Discordant Spirit", ); - match &def.condition { - Some(TriggerCondition::Not { condition }) => { - assert!( - matches!( - condition.as_ref(), - TriggerCondition::DuringPlayersTurn { - player: PlayerFilter::Controller, - } - ), - "expected Not(DuringPlayersTurn {{ Controller }}), got {condition:?}" - ); - } - other => panic!("expected Not(DuringPlayersTurn), got {other:?}"), - } + assert_eq!( + def.condition, + Some(TriggerCondition::DuringPlayersTurn { + player: PlayerFilter::Opponent, + }) + ); } #[test] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index dd3f1e1fd8..a09c79c42c 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -7466,7 +7466,13 @@ pub enum StaticCondition { Unrecognized { text: String, }, + /// CR 102.1: The active player is the controller of this static ability. DuringYourTurn, + /// CR 102.3 + CR 805.4a: The active player is an opponent of this static + /// ability's controller. This is deliberately distinct from + /// `Not(DuringYourTurn)`: in team games, a teammate may be the active + /// player while the controller's team still has the turn. + DuringOpponentsTurn, /// CR 105.2 + CR 611.3a: True when the source permanent shares a color with /// the most common color among all permanents on the battlefield (including /// any color tied for most common). Used by Heroic Defiance's "gets +3/+3 @@ -7871,6 +7877,34 @@ pub enum ParsedCondition { /// ability-resolution layer), the same way `ParsedCondition::And` mirrors /// `AbilityCondition::And`. IsYourTurn, + /// CR 102.3 + CR 805.4a: True when the active player belongs to a team the + /// scoped player is not on — "a player's opponents are all players not on + /// their team", so the turn genuinely belongs to an opponent. + /// + /// Deliberately NOT `Not(IsYourTurn)`. `IsYourTurn` compares the scoped + /// player against the single `active_player` SEAT, but under the shared + /// team turns option (CR 805.4: "Each team takes turns rather than each + /// player" — always on in Two-Headed Giant, CR 810.2) a turn belongs to a + /// TEAM. When a teammate holds `active_player` the active team is still the + /// scoped player's own team, yet `Not(IsYourTurn)` reports it as an + /// opponent's turn and wrongly opens an opponent-turn window. + /// + /// For the same reason this is not a `PlayerRelation` parameterization of + /// `IsYourTurn`: the two predicates are asked at different granularities + /// (seat vs team), so they are independent leaves rather than two values of + /// one scope axis. + /// + /// Team membership is never re-derived here — evaluation delegates to the + /// single authority `crate::game::players::is_opponent`. + IsOpponentsTurn, + /// CR 503.1: True when the game is currently in the upkeep step. A + /// turn-structure predicate with NO player scope — it asks only "is it an + /// upkeep step", not whose. Player scope composes at the restriction layer: + /// "during an opponent's upkeep" is `And([IsOpponentsTurn, IsDuringUpkeep])` + /// (CR 102.1 fixes the active player from the turn), reusing the + /// turn-scope leaf rather than adding a `DuringOpponents*` restriction + /// sibling per step. + IsDuringUpkeep, /// CR 601.3d + CR 702.8a + CR 608.2c: The in-flight spell being cast targets at /// least one object that matches `filter`. Gates a target-dependent casting /// permission (Timely Ward — "you may cast this spell as though it had flash if diff --git a/crates/engine/tests/integration/coerced_attack_punisher.rs b/crates/engine/tests/integration/coerced_attack_punisher.rs index 35d79528ec..d2a5152dfe 100644 --- a/crates/engine/tests/integration/coerced_attack_punisher.rs +++ b/crates/engine/tests/integration/coerced_attack_punisher.rs @@ -662,9 +662,7 @@ fn activation_restrictions(p: &ParsedAbilities) -> Vec { fn opponents_turn() -> ActivationRestriction { ActivationRestriction::RequiresCondition { - condition: Some(ParsedCondition::Not { - condition: Box::new(ParsedCondition::IsYourTurn), - }), + condition: Some(ParsedCondition::IsOpponentsTurn), } } @@ -715,21 +713,14 @@ fn test10_nettling_comma_form_fixed_for_free() { } // --------------------------------------------------------------------------- -// Test 10b — the compound restriction feeds the EXISTING runtime gate. We drive -// the actual runtime condition evaluation (Not(IsYourTurn)) through -// player_matches_target_filter_in_state analog: assert the parsed condition -// evaluates active-player-relative (own turn illegal, opponent turn legal). -// The runtime arm is `state.active_player != player`. +// Test 10b — the compound restriction retains the team-aware opponent-turn +// predicate that the production restriction gate evaluates. // --------------------------------------------------------------------------- #[test] fn test10b_opponents_turn_condition_semantics() { - // The parsed restriction's condition is Not(IsYourTurn); the runtime arm at - // restrictions.rs:1385 evaluates IsYourTurn as `state.active_player == - // player`. We validate the enforced *shape* the runtime consumes, and that - // it is NOT the vacuous None (which would always pass). The full runtime - // path is exercised by the existing restriction-enforcement suite; here we - // pin that the parser hands it the enforced condition (revert of Step 6b - // would yield None → always-legal on the controller's own turn). + // The parsed restriction's condition is IsOpponentsTurn, evaluated through + // game::players::is_opponent by the production gate. Pin its non-vacuous + // shape here; the production-path test below proves its runtime behavior. let p = parse("Maddening Imp", MADDENING_IMP, &["Creature"], &["Imp"]); let restrictions = activation_restrictions(&p); let turn_gate = restrictions @@ -740,12 +731,7 @@ fn test10b_opponents_turn_condition_semantics() { }) .expect("a RequiresCondition timing gate is present"); // Revert-failing: Step 6b turns the vacuous None into the enforced condition. - assert_eq!( - turn_gate, - Some(ParsedCondition::Not { - condition: Box::new(ParsedCondition::IsYourTurn), - }) - ); + assert_eq!(turn_gate, Some(ParsedCondition::IsOpponentsTurn)); // The combat-window half is present and enforced. assert!(restrictions .iter() @@ -945,7 +931,7 @@ fn test_a_sirens_call_cast_pipeline_multiplayer_discrimination() { // TEST B — Maddening Imp activation legality through the production restriction // evaluator. // -// Feeds the parsed compound activation timing ([Not(IsYourTurn), +// Feeds the parsed compound activation timing ([IsOpponentsTurn, // BeforeAttackersDeclared]) into the production `check_activation_restrictions` // and drives it across three turn/phase states. Before Step 6b the parser // emitted a single vacuous `RequiresCondition{None}`, which is ALWAYS legal — so @@ -987,7 +973,7 @@ fn test_b_maddening_imp_activation_legality_production_path() { check_activation_restrictions(runner.state(), P0, imp, 0, &restrictions) }; - // Case 1: the controller's OWN turn, pre-combat. `Not(IsYourTurn)` fails — + // Case 1: the controller's OWN turn, pre-combat. IsOpponentsTurn fails — // illegal. { let st = runner.state_mut(); @@ -998,13 +984,13 @@ fn test_b_maddening_imp_activation_legality_production_path() { } assert!( check(&runner).is_err(), - "activation on the controller's own turn must be illegal (Not(IsYourTurn) fails)" + "activation on the controller's own turn must be illegal (IsOpponentsTurn fails)" ); // Case 2: an OPPONENT's pre-combat main. The activating (non-active) player - // P0 realistically holds priority to activate the ability. `Not(IsYourTurn)` - // passes (P0 != active P1) AND `BeforeAttackersDeclared` passes (a phase-based - // window, independent of who holds priority — CR 508.1/508.2) — legal. + // P0 realistically holds priority to activate the ability. IsOpponentsTurn + // passes AND `BeforeAttackersDeclared` passes (a phase-based window, + // independent of who holds priority — CR 508.1/508.2) — legal. { let st = runner.state_mut(); st.active_player = P1; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 5b5121675c..7485b2fae1 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -39,7 +39,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | 25 | Wrong / dropped effect duration | 28 | oracle_nom/duration.rs — add until-event / two-turn / permanent duration variants | | 26 | Delayed / future-phase trigger flattened to immediate effect | 20 | add-trigger: wrap future-phase effects in CreateDelayedTrigger | | 27 | Cross-target group / shared-quality constraint dropped | 20 | oracle_target.rs multi_target — add SameController/SameZone/DistinctNames/Parity constraints | -| 28 | Trigger/activation timing or ordinal restriction dropped | 16 | oracle_casting.rs scan_timing_restrictions + trigger constraint parsing | +| 28 | Trigger/activation timing or ordinal restriction dropped | 13 | oracle_casting.rs scan_timing_restrictions + trigger constraint parsing | | 30 | Token/named-card name corrupted by normalization or overrun | 10 | oracle_util.rs SELF_REF normalization + Named-filter parsing — guard literal 'named X' spans | | 31 | Other / uncategorized misparse | 5 | manual triage | @@ -5057,7 +5057,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 28. Trigger/activation timing or ordinal restriction dropped (14 cards) +### 28. Trigger/activation timing or ordinal restriction dropped (13 cards) **Signature.** A timing/scope restriction (OnlyDuringYourTurn / OncePerTurn / 'during an opponent's turn' / Nth-spell ordinal / cast-timing) is null; the constraint tail is not parsed. @@ -5076,7 +5076,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Shadowheart, Sharran Cleric - Skarrgan Hellkite - Tomb Tyrant -- Trade Caravan - Uthros Research Craft - Uthros, Titanic Godcore