Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion crates/engine/src/game/effects/forage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::types::ability::{
FilterProp, MultiTargetSpec, PlayerFilter, QuantityExpr, ResolvedAbility, TargetChoiceTiming,
TargetFilter, TargetRef, TypedFilter,
};
use crate::types::events::GameEvent;
use crate::types::events::{GameEvent, PlayerActionKind};
use crate::types::game_state::GameState;
use crate::types::identifiers::ObjectId;
use crate::types::player::PlayerId;
Expand Down Expand Up @@ -115,6 +115,7 @@ pub(crate) fn resolve(
branches.push(sacrifice_food_branch());
}

let foraged = !branches.is_empty();
match branches.len() {
// CR 701.61a: neither mode performable — foraging does nothing.
0 => {}
Expand Down Expand Up @@ -146,6 +147,16 @@ pub(crate) fn resolve(
}
}

if foraged {
events.push(GameEvent::PlayerPerformedAction {
player_id: controller,
action: PlayerActionKind::Forage,
look_count: None,
scry_bottom_count: None,
scry_top_count: None,
});
}

events.push(GameEvent::EffectResolved {
kind: EffectKind::Forage,
source_id: ability.source_id,
Expand Down Expand Up @@ -212,6 +223,16 @@ mod tests {
..
}
)));
assert!(
!events.iter().any(|event| matches!(
event,
GameEvent::PlayerPerformedAction {
action: PlayerActionKind::Forage,
..
}
)),
"an impossible forage must not emit a player-action event"
);
}

/// CR 701.61a (exile mode): three graveyard cards and no Food prompts an
Expand Down Expand Up @@ -283,6 +304,14 @@ mod tests {
state.waiting_for,
WaitingFor::ChooseOneOfBranch { .. }
));
assert!(events.iter().any(|event| matches!(
event,
GameEvent::PlayerPerformedAction {
player_id: PlayerId(0),
action: PlayerActionKind::Forage,
..
}
)));
}

/// CR 701.61a: both modes available — the forager chooses which via a modal prompt.
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/parser/oracle_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15849,6 +15849,9 @@ fn parse_player_action_phrase(text: &str) -> Option<PlayerActionKind> {
if let Ok(("", action)) = parse_proliferate_player_action(text) {
return Some(action);
}
if let Ok(("", action)) = parse_forage_player_action(text) {
return Some(action);
}
match text {
"search your library" | "searches their library" => Some(PlayerActionKind::SearchedLibrary),
"scry" | "scries" => Some(PlayerActionKind::Scry),
Expand Down Expand Up @@ -15877,6 +15880,15 @@ fn parse_proliferate_player_action(input: &str) -> OracleResult<'_, PlayerAction
.parse(input)
}

fn parse_forage_player_action(input: &str) -> OracleResult<'_, PlayerActionKind> {
// CR 701.61a: Forage — exile three cards from your graveyard or sacrifice a Food.
all_consuming(alt((
value(PlayerActionKind::Forage, tag("forage")),
value(PlayerActionKind::Forage, tag("forages")),
)))
.parse(input)
}

/// Parse "whenever you cast your Nth spell each turn" (or "in a turn") and
/// "whenever an opponent casts their Nth [noncreature] spell each turn" into a SpellCast
/// trigger with a NthSpellThisTurn constraint.
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/parser/oracle_trigger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11901,6 +11901,17 @@ fn trigger_you_proliferate() {
);
}

#[test]
fn trigger_you_forage() {
let def = parse_trigger_line(
"Whenever you forage, put a +1/+1 counter on this creature.",
"Corpseberry Cultivator",
);
assert_eq!(def.mode, TriggerMode::PlayerPerformedAction);
assert_eq!(def.valid_target, Some(TargetFilter::Controller));
assert_eq!(def.player_actions, Some(vec![PlayerActionKind::Forage]));
}

#[test]
fn trigger_you_scry_or_surveil() {
let def = parse_trigger_line(
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/types/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ pub enum PlayerActionKind {
Proliferate,
/// CR 701.16a: A player investigated (created a Clue token).
Investigate,
/// CR 701.61a: A player foraged by exiling three cards from their graveyard
/// or sacrificing a Food.
Forage,
/// A player completed a draw instruction that delivered at least
/// one card. Emitted once per settled draw INSTRUCTION (at draw-sequence
/// completion), not once per card — so a multi-card draw records a single
Expand Down
62 changes: 62 additions & 0 deletions crates/engine/tests/integration/issue_7221_forage_trigger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use engine::game::scenario::{GameRunner, GameScenario, P0};
use engine::types::actions::GameAction;
use engine::types::counter::CounterType;
use engine::types::game_state::WaitingFor;
use engine::types::identifiers::ObjectId;
use engine::types::phase::Phase;

const CORPSEBERRY_CULTIVATOR: &str = "At the beginning of combat on your turn, you may forage. \
(Exile three cards from your graveyard or sacrifice a Food.)\n\
Whenever you forage, put a +1/+1 counter on this creature.";

fn p1p1(runner: &GameRunner, id: ObjectId) -> u32 {
runner.state().objects[&id]
.counters
.get(&CounterType::Plus1Plus1)
.copied()
.unwrap_or(0)
}

fn resolve_until_counter(runner: &mut GameRunner, cultivator: ObjectId) {
for _ in 0..200 {
if p1p1(runner, cultivator) > 0 {
return;
}
match &runner.state().waiting_for {
WaitingFor::OptionalEffectChoice { .. } => {
runner
.act(GameAction::DecideOptionalEffect { accept: true })
.expect("accept the forage trigger");
}
WaitingFor::EffectZoneChoice { cards, count, .. } => {
let cards = cards.iter().take(*count).copied().collect();
runner
.act(GameAction::SelectCards { cards })
.expect("exile three cards to forage");
}
_ => {
runner
.act(GameAction::PassPriority)
.expect("advance the game");
}
}
}
panic!("forage trigger did not resolve");
}

#[test]
fn foraging_from_graveyard_triggers_corpseberry_cultivator() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
let cultivator = scenario
.add_creature_from_oracle(P0, "Corpseberry Cultivator", 2, 3, CORPSEBERRY_CULTIVATOR)
.id();
for _ in 0..3 {
scenario.add_creature_to_graveyard(P0, "Fodder", 1, 1);
}

let mut runner = scenario.build();
resolve_until_counter(&mut runner, cultivator);

assert_eq!(p1p1(&runner, cultivator), 1);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,7 @@ mod issue_7087_recruit_discard_provenance;
mod issue_709_regression;
mod issue_718_dina_sacrifice_draw;
mod issue_7212_recruit_sibling_trigger;
mod issue_7221_forage_trigger;
mod issue_7232_expend_auto_land_payment;
mod issue_735_amalia_power_threshold;
mod issue_735_cost_paid_object_non_regression;
Expand Down
Loading