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
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 @@ -7378,18 +7378,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
129 changes: 129 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,135 @@ 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);
let condition = super::shared::parse_unless_static_condition(&tp)?;
// Coverage-honesty gate (CR 604.1): only emit the forced-attack static when the
// `unless` gate is a FULLY-MODELED condition. `parse_unless_static_condition`
// wraps an unrecognized inner clause as `Not(Unrecognized)` — which (a) the
// coverage detector's TOP-LEVEL `Unrecognized` check misses, so the card is
// falsely reported supported, and (b) evaluates permanently false at runtime
// (`Unrecognized` is true; the wrapping `Not` negates it), silently disabling
// the whole requirement. Decline instead so the line stays honestly unsupported
// (coverage red) rather than shipping a broken static.
if static_condition_contains_unrecognized(&condition) {
return None;
}
def.condition = Some(condition);
Some(def)
}

/// True when `condition` contains an `Unrecognized` clause ANYWHERE in its tree
/// (recursing through the `Not` / `And` / `Or` combinators). Used by the
/// forced-attack parser to decline a not-fully-modeled `unless` gate: the
/// coverage detector only flags a TOP-LEVEL `Unrecognized`, so a nested one
/// (`Not(Unrecognized)`, the shape `parse_unless_static_condition` emits for an
/// unknown clause) would otherwise mark the card supported while its requirement
/// is permanently inert at runtime.
fn static_condition_contains_unrecognized(condition: &StaticCondition) -> bool {
match condition {
StaticCondition::Unrecognized { .. } => true,
// The only sub-condition-embedding variants — recurse through them. If a NEW
// combinator variant that nests `StaticCondition` is added, extend this match;
// the leaf wildcard below would otherwise hide an unrecognized clause inside
// it. Every remaining variant is a leaf that cannot contain a nested clause.
StaticCondition::Not { condition } => static_condition_contains_unrecognized(condition),
StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions
.iter()
.any(static_condition_contains_unrecognized),
_ => false,
}
}

/// 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