Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6160,7 +6160,7 @@ mod tests {
.static_definitions
.push(StaticDefinition::new(
crate::types::statics::StaticMode::MustAttackPlayer {
player: PlayerId(1),
player: PlayerId(1).into(),
},
));
let goaded = make_creature(&mut state, 2);
Expand Down
11 changes: 8 additions & 3 deletions crates/engine/src/database/encore_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::types::keywords::Keyword;
use crate::types::mana::{ManaColor, ManaCost, ManaCostShard};
use crate::types::phase::Phase;
use crate::types::player::PlayerId;
use crate::types::statics::StaticMode;
use crate::types::statics::{RequiredDefender, StaticMode};
use crate::types::zones::Zone;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -207,7 +207,9 @@ fn encore_activation_creates_attacking_haste_copy_per_opponent() {
must_attack.modifications.iter().any(|m| matches!(
m,
ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player },
mode: StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player },
},
} if *player == PlayerId(1)
)),
"token must be required to attack the opponent"
Expand Down Expand Up @@ -305,7 +307,10 @@ fn encore_three_player_one_token_per_opponent_then_sacrificed_at_end_step() {
.find_map(|ce| {
ce.modifications.iter().find_map(|m| match m {
ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player },
mode:
StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player },
},
} => Some(*player),
_ => None,
})
Expand Down
345 changes: 314 additions & 31 deletions crates/engine/src/game/combat.rs

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions crates/engine/src/game/effects/encore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use crate::types::events::GameEvent;
use crate::types::game_state::{DelayedTrigger, GameState};
use crate::types::keywords::Keyword;
use crate::types::phase::Phase;
use crate::types::statics::StaticMode;
use crate::types::statics::{RequiredDefender, StaticMode};

/// CR 702.141a: Resolve a card's Encore ability — for each opponent of the
/// activating player, create a haste-bearing token copy of the exiled source
Expand Down Expand Up @@ -94,7 +94,10 @@ pub fn resolve(
Duration::UntilEndOfTurn,
TargetFilter::SpecificObject { id: token_id },
vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player: opponent },
// CR 611.2: snapshot the specific opponent at resolution.
mode: StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player: opponent },
},
}],
None,
);
Expand Down
15 changes: 11 additions & 4 deletions crates/engine/src/game/effects/force_attack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::types::ability::{
};
use crate::types::events::GameEvent;
use crate::types::game_state::GameState;
use crate::types::statics::StaticMode;
use crate::types::statics::{RequiredDefender, StaticMode};

/// CR 508.1d: Force attack — the target creature must attack the required player
/// this turn/combat if able.
Expand Down Expand Up @@ -35,7 +35,10 @@ pub fn resolve(
duration.clone(),
TargetFilter::SpecificObject { id: obj_id },
vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player },
// CR 611.2: the required defender is snapshotted at resolution.
mode: StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player },
},
}],
None,
);
Expand Down Expand Up @@ -110,7 +113,9 @@ mod tests {
matches!(
m,
ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player },
mode: StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player },
},
} if *player == PlayerId(0)
)
}));
Expand Down Expand Up @@ -161,7 +166,9 @@ mod tests {
matches!(
m,
ContinuousModification::AddStaticMode {
mode: StaticMode::MustAttackPlayer { player },
mode: StaticMode::MustAttackPlayer {
player: RequiredDefender::Fixed { player },
},
} if *player == PlayerId(1)
)
}));
Expand Down
12 changes: 8 additions & 4 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7309,18 +7309,22 @@ fn parse_for_each_object_copy_parts(
/// repeat an earlier choice (confirmed by the "Offering" cycle ruling —
/// Benevolent/Infernal/Intellectual/Sylvan Offering — issue #6381). Either
/// way, the engine index is derived from chain position, not the ordinal.
/// CR 102.3 + CR 608.2d: Recognize the "with the most life [among
/// CR 102.2 / CR 102.3 + CR 608.2d: Recognize the "with the most life [among
/// your opponents]" qualifier on a "choose an opponent" instruction and lower it
/// to the equivalent `PlayerFilter::PlayerAttribute` restriction: each candidate
/// opponent whose life total is `>=` the maximum life total across all opponents.
/// CR 102.3 scopes the candidate set to opponents; CR 608.2d lets the controller
/// pick ONE qualifying opponent (resolving ties) when multiple share the maximum.
/// CR 102.2 (two-player) / CR 102.3 (team multiplayer) scope the candidate set to
/// opponents. For the resolution-time "choose an opponent …" consumers, CR 608.2d
/// lets the controller pick ONE qualifying opponent (resolving ties) when multiple
/// share the maximum. (The static forced-attack consumer reuses this SAME filter
/// but is not a resolution-time choice — there a most-life tie is resolved under
/// CR 508.1b/d at declare-attackers; see `parse_required_defender_selector`.)
/// The candidate's life is read PER-CANDIDATE (`PlayerScope::ScopedPlayer`); the
/// `value` threshold is the controller-relative max (`PlayerScope::Opponent {
/// aggregate: Max }`), composed from existing typed enums rather than a bespoke
/// `MostLife` sibling. Consumes the qualifier text; returns the parsed
/// restriction so the caller can attach it to `ChoiceType::Opponent`.
fn parse_opponent_most_life_restriction(input: &str) -> OracleResult<'_, PlayerFilter> {
pub(crate) fn parse_opponent_most_life_restriction(input: &str) -> OracleResult<'_, PlayerFilter> {
let (input, _) = preceded(
tag(" with the most life"),
opt(tag(" among your opponents")),
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/parser/oracle_static/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,18 @@ pub(crate) fn parse_static_line_inner(
return Some(def);
}

// CR 508.1d + CR 604.1: "<subject> attacks <player-class> each combat if able
// [unless ...]" — a static attack requirement whose required defender is a
// live-evaluated player class (Galactus: "an opponent with the most life among
// your opponents ... unless you control a creature named ..."). Richer than the
// bare scoped must-attack below: the selector form is disjoint (returns None
// without a defender phrase), and the wrapper's flavor-label strip is a
// retry-on-failure (never fires when the body already parses), so ordering
// before `try_parse_scoped_must_attack_block` is safe.
if let Some(def) = parse_forced_attack_defender_static(&text) {
return Some(def);
}

// CR 508.1d / CR 509.1c: Subject-scoped "attack/block each combat if able" patterns.
// These apply MustAttack/MustBlock to a class of creatures (not just self).
// Compound forms ("attacks or blocks") produce multiple statics; return the first here.
Expand Down
95 changes: 95 additions & 0 deletions crates/engine/src/parser/oracle_static/evasion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2464,6 +2464,101 @@ pub(crate) fn parse_subject_combat_rule_static(text: &str) -> Option<StaticDefin
None
}

/// CR 102.2 / CR 102.3 + CR 508.1b / CR 508.1d: the required defending player
/// class after "attacks ". Currently "a[n] opponent[ with the most life [among
/// your opponents]]". CR 102.2 (two-player) / CR 102.3 (team multiplayer) scope
/// the candidate set to opponents. This lowers to a static attack REQUIREMENT (CR
/// 508.1d), re-evaluated each declare-attackers step; when the class holds more
/// than one legal defender (e.g. a most-life tie) CR 508.1b — the active player
/// announcing which player each attacker attacks — is where the player picks among
/// the tied legal defenders. NOT CR 608.2d: that rule governs choices offered
/// while resolving a spell or ability, not this continuous static requirement.
/// Structured as sequential combinators so a future "a[n] player" (relation `All`)
/// arm slots in without disturbing the opponent path. Reuses the shared
/// `parse_opponent_most_life_restriction` selector rather than re-deriving the
/// `PlayerAttribute` shape.
fn parse_required_defender_selector(input: &str) -> OracleResult<'_, PlayerFilter> {
let (input, _) = alt((tag::<_, _, OracleError<'_>>("an "), tag("a "))).parse(input)?;
let (input, _) = tag("opponent").parse(input)?;
// Optional "with the most life [among your opponents]" qualifier; fall back to
// the bare `Opponent` class when the qualifier is absent ("an opponent").
match super::oracle_effect::parse_opponent_most_life_restriction(input) {
Ok((rest, filter)) => Ok((rest, filter)),
Err(_) => Ok((input, PlayerFilter::Opponent)),
}
}

/// CR 508.1d: `attacks <player-class> each combat if able` — the required-attack
/// predicate. Consumes the verb, the defender selector, and the recurring-combat
/// suffix, returning the selected `PlayerFilter` for the required defender.
fn parse_attacks_required_defender_nom(input: &str) -> OracleResult<'_, PlayerFilter> {
let (input, _) = tag::<_, _, OracleError<'_>>("attacks ").parse(input)?;
let (input, filter) = parse_required_defender_selector(input)?;
let (input, _) = alt((
tag::<_, _, OracleError<'_>>(" each combat if able"),
tag(" each turn if able"),
))
.parse(input)?;
Ok((input, filter))
}

/// CR 508.1d + CR 508.1b + CR 604.1 / CR 604.2 + CR 102.2 / CR 102.3: "<subject>
/// attacks <player-class> each combat if able [unless <condition>]" — a static
/// attack requirement (CR 508.1d) whose defending player is a live-evaluated class
/// (Galactus: "an opponent with the most life among your opponents"; CR 102.2 /
/// CR 102.3 scope "opponent", CR 508.1b covers the active player's choice among
/// tied legal defenders). Emits
/// `MustAttackPlayer { RequiredDefender::Matching { filter } }`, re-evaluated each
/// declare-attackers step by the combat resolver.
///
/// The dispatcher receives the self-ref-normalized line WITHOUT the CR 207.2c /
/// CR 207.2d ability-/flavor-word label stripped (Galactus's line arrives as
/// "Insatiable Hunger — ~ attacks …"), so this wrapper tries the line as-is, then
/// strips a leading flavor label via `strip_flavor_word_with_name` and retries
/// ONCE on the body — mirroring the single-hop retry in
/// `parse_static_line_multi_inner`. The strip is class-general (any leading
/// flavor label preceding this static form) and safe: a false-positive strip
/// yields a body that fails the strict subject / "attacks … each combat if able"
/// match and returns `None`. The full Oracle line (label included) is preserved
/// as the definition's description for display / round-trip.
pub(crate) fn parse_forced_attack_defender_static(text: &str) -> Option<StaticDefinition> {
parse_forced_attack_defender_static_body(text).or_else(|| {
let (_label, body) = super::oracle_modal::strip_flavor_word_with_name(text)?;
parse_forced_attack_defender_static_body(&body).map(|def| def.description(text.to_string()))
})
}

fn parse_forced_attack_defender_static_body(text: &str) -> Option<StaticDefinition> {
let lower = text.to_lowercase();
let (subject_lower, filter, rest) =
nom_primitives::scan_preceded(&lower, parse_attacks_required_defender_nom)?;
let subject = text[..subject_lower.len()].trim();
let affected = parse_rule_static_subject_filter(subject)?;
let mut def = StaticDefinition::new(StaticMode::MustAttackPlayer {
player: RequiredDefender::Matching { filter },
})
.affected(affected)
.description(text.to_string());
// Consume an optional trailing period; any remaining tail MUST be a recognized
// `unless` gate (CR 604.1) — otherwise decline so an unrecognized rider cannot
// yield a half-parsed static (coverage stays honest / red).
let (rest, _) = opt(tag::<_, _, OracleError<'_>>(".")).parse(rest).ok()?;
let rest = rest.trim();
if rest.is_empty() {
return Some(def);
}
// The ONLY permitted tail is an `unless` clause, and it must begin RIGHT HERE.
// Requiring `rest` to start with `unless ` (rather than letting the whole-text
// `unless` scan below find it anywhere) is what stops an unmodelled rider
// between the recurring-combat suffix and `unless` from being silently
// swallowed — e.g. "... each combat if able <rider> unless <cond>" must decline,
// not parse as if the rider were absent.
tag::<_, _, OracleError<'_>>("unless ").parse(rest).ok()?;
let tp = TextPair::new(text, &lower);
def.condition = Some(super::shared::parse_unless_static_condition(&tp)?);
Some(def)
}

/// CR 702.122a / 702.171a / 702.184c: nom parser for the crew/saddle/station
/// power-contribution modifier predicate. Composes the named action-list prefix
/// (which records the affected keyword actions) with the modifier tail.
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/parser/oracle_static/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ mod prelude {
CastCostMode, CastExtraCost, CastFreeOrigin, CastFrequency, CastingProhibitionCondition,
CombatAloneAction, CombatAloneRequirement, CostModifyMode, CostPaymentProhibition,
CrewAction, CrewContributionKind, ExileCardPool, ExileCastCost, ExileCastTiming,
HandSizeModification, ProhibitionScope, StaticMode, SuppressedTriggerEvent, TriggerCause,
ZoneChangeQualifier,
HandSizeModification, ProhibitionScope, RequiredDefender, StaticMode,
SuppressedTriggerEvent, TriggerCause, ZoneChangeQualifier,
};
pub(super) use crate::types::zones::Zone;
}
Expand Down
Loading
Loading