Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 53 additions & 13 deletions crates/engine/src/game/combat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ use crate::types::resolved_commands::{
ResolvedCombatMembershipReplayInvariantError,
};
use crate::types::statics::{
AttackDefenderScope, BlockExceptionKind, CombatAloneAction, CombatAloneRequirement, StaticMode,
StaticModeKind,
AttackDefenderScope, BlockExceptionKind, CombatAloneAction, CombatAloneRequirement,
RequiredDefender, StaticMode, StaticModeKind,
};
use crate::types::zones::Zone;

Expand Down Expand Up @@ -3146,10 +3146,50 @@ pub(crate) fn must_attack_player_directives_for_creature(
state: &GameState,
obj: &GameObject,
) -> Vec<(PlayerId, Option<ObjectId>)> {
super::functioning_abilities::active_static_definitions(state, obj)
.filter_map(|sd| match sd.mode {
StaticMode::MustAttackPlayer { player } => Some((player, sd.source_object)),
_ => None,
// CR 508.1d + CR 611.2 / CR 604.2: MustAttackPlayer directives; the required
// defender may be a resolution-time snapshot (`Fixed`, ForceAttack/Encore) or
// a live static class (`Matching`, Galactus) re-evaluated each
// declare-attackers step. Collect (defender, source_object, source_controller)
// triples first so the `active_static_definitions` borrow is dropped before we
// call `matches_player_scope`, which re-borrows `state.players`.
let directives: Vec<(RequiredDefender, Option<ObjectId>, Option<PlayerId>)> =
super::functioning_abilities::active_static_definitions(state, obj)
.filter_map(|sd| match &sd.mode {
StaticMode::MustAttackPlayer { player } => {
Some((player.clone(), sd.source_object, sd.source_controller))
}
_ => None,
})
.collect();
directives
.into_iter()
.flat_map(|(defender, src, src_ctrl)| match defender {
// CR 611.2: a snapshotted id — used verbatim.
RequiredDefender::Fixed { player } => vec![(player, src)],
// CR 604.1 / CR 604.2 + CR 102.3: re-evaluate the class each check.
// "you"/"your opponents" resolves to the static's controller (the
// graft-time snapshot, else the carrier's controller). Yields ALL
// players in the class (e.g. every opponent tied for the most life);
// the max-requirement solver (CR 508.1d) then forces attacking one.
RequiredDefender::Matching { filter } => {
let controller = src_ctrl.unwrap_or(obj.controller);
let source_id = src.unwrap_or(obj.id);
// Deliberate O(n^2): `matches_player_scope` re-`find`s the player
// by id (game/effects/mod.rs), so passing each `p.id` re-scans the
// (tiny) player set. Reusing the canonical evaluator is worth the
// redundant lookup at 2-6 players; a batch `players_matching_scope`
// helper is the future extraction if a hot path ever appears.
state
.players
.iter()
.filter(|p| {
crate::game::effects::matches_player_scope(
state, p.id, &filter, controller, source_id,
)
})
.map(|p| (p.id, src))
.collect()
}
})
.collect()
}
Expand Down Expand Up @@ -12143,7 +12183,7 @@ mod tests {
// so it changes no assertion; it upholds the no-`affected:None` invariant.
.push(
StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(2),
player: PlayerId(2).into(),
})
.affected(TargetFilter::SelfRef),
);
Expand Down Expand Up @@ -12216,7 +12256,7 @@ mod tests {
.static_definitions
.push(
StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(1),
player: PlayerId(1).into(),
})
.affected(TargetFilter::SelfRef)
.source_object(ObjectId(9000)),
Expand All @@ -12236,15 +12276,15 @@ mod tests {
for src in [ObjectId(9001), ObjectId(9002)] {
defs.push(
StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(1),
player: PlayerId(1).into(),
})
.affected(TargetFilter::SelfRef)
.source_object(src),
);
}
defs.push(
StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(2),
player: PlayerId(2).into(),
})
.affected(TargetFilter::SelfRef)
.source_object(ObjectId(9003)),
Expand Down Expand Up @@ -12677,7 +12717,7 @@ mod tests {
.unwrap()
.static_definitions
.push(StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(2),
player: PlayerId(2).into(),
}));

// Attacking the wrong player (P1) while P2 is a legal target: illegal. New
Expand Down Expand Up @@ -12786,7 +12826,7 @@ mod tests {
.unwrap()
.static_definitions
.push(StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(1),
player: PlayerId(1).into(),
}));

// New contract (CR 508.1d): the MustAttackPlayer requirement is scored by the
Expand All @@ -12806,7 +12846,7 @@ mod tests {
.unwrap()
.static_definitions
.push(StaticDefinition::new(StaticMode::MustAttackPlayer {
player: PlayerId(1),
player: PlayerId(1).into(),
}));
let planeswalker = create_planeswalker(&mut state, PlayerId(1), "Required Player's Walker");

Expand Down
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
2 changes: 1 addition & 1 deletion crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7320,7 +7320,7 @@ fn parse_for_each_object_copy_parts(
/// 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
78 changes: 78 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,84 @@ pub(crate) fn parse_subject_combat_rule_static(text: &str) -> Option<StaticDefin
None
}

/// CR 102.3 + CR 608.2d: the required defending player class after "attacks ".
/// Currently "a[n] opponent[ with the most life [among your opponents]]".
/// 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 (CR 102.3 candidate scoping +
/// CR 608.2d tie resolution) 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)),
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// 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 604.1 / CR 604.2 + CR 102.3: "<subject> attacks <player-class>
/// each combat if able [unless <condition>]" — a static attack requirement whose
/// defending player is a live-evaluated class (Galactus: "an opponent with the
/// most life among your opponents"). 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()?;
if rest.trim().is_empty() {
return Some(def);
}
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