Skip to content

[Card Bug] "Whenever you forage" never fires — forage missing from the player-action seam (CR 701.61a) #7221

Description

@cuinhellcat

Card(s)

Corpseberry Cultivator. Any "Whenever you forage" card is affected.

Build/version

phase-engine 0.44.0, base 1bb6c1d.

Game mode

P2P

Actual behavior

Foraging never fires "Whenever you forage" — Corpseberry Cultivator foraged at the beginning of combat (sacrificing a Food) and got no +1/+1 counter.

The engine declares the gap itself; from the game-state dump:

"unimplemented_mechanics": ["Trigger: Whenever you forage"]

The effect parses correctly (PutCounter{ P1P1, SelfRef }); only the triggering condition is lost, as mode: {"Unknown": "Whenever you forage"}.

Expected behavior

CR 701.61a: completing a forage fires the trigger and adds the counter.

Root cause

Same shape as #4042 ("Whenever you investigate" → TriggerMode::Unknown), and broken on both ends of the shared player-action seam:

  1. parse_player_action_phrase (parser/oracle_trigger.rs) has arms for scry, surveil, collect evidence, investigate, … but none for forage, so the trigger lands as TriggerMode::Unknown and trigger_matcher returns None.
  2. effects::forage emits only EffectResolved{ kind: Forage }, which carries no acting player and so cannot serve a player-scoped trigger. Its siblings effects::surveil / investigate / proliferate each announce GameEvent::PlayerPerformedAction; forage does not, and PlayerActionKind has no Forage variant.

So fixing either end alone changes nothing.

Note TriggerMode::Forage exists and looks wired, but is a dead end: trigger_matcher maps it into the arm ending => match_visit_attraction, which matches only AttractionVisited. Implementing against it would be the wrong seam — proliferate, the closest analogue, routes through PlayerPerformedAction instead.

Suggested fix

Three additions, mirroring proliferate exactly. Verified locally: cargo test --test integration 4804 passed | 0 failed, cargo test -p phase-engine --lib 18832 passed | 0 failed, clippy clean.

// types/events.rs — PlayerActionKind
/// CR 701.61a: A player foraged (exiled three cards from their graveyard,
/// or sacrificed a Food).
Forage,

// parser/oracle_trigger.rs — parse_player_action_phrase, beside "surveil"
"forage" | "forages" => Some(PlayerActionKind::Forage),

// game/effects/forage.rs — resolve(), after the branch match
// Gated: with neither three graveyard cards nor a Food, no forage occurred
// (CR 701.61a), so the trigger must not fire.
let foraged = !branches.is_empty();   // captured before the match consumes `branches`
if foraged {
    events.push(GameEvent::PlayerPerformedAction {
        player_id: controller,
        action: PlayerActionKind::Forage,
        look_count: None,
        scry_bottom_count: None,
        scry_top_count: None,
    });
}

Steps to reproduce

Battlefield: Corpseberry Cultivator, three cards in your graveyard. Accept the begin-combat forage, exile three cards — no counter appears.

As a test (crates/engine/tests/integration/), driving the real prompt chain:

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.";

#[test]
fn foraging_puts_a_counter_on_the_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();

    for _ in 0..200 {
        if p1p1(&runner, cultivator) > 0 {
            break;
        }
        match &runner.state().waiting_for {
            WaitingFor::OptionalEffectChoice { .. } => {
                runner.act(GameAction::DecideOptionalEffect { accept: true }).unwrap();
            }
            WaitingFor::EffectZoneChoice { cards, count, .. } => {
                let pick: Vec<_> = cards.iter().take(*count).copied().collect();
                runner.act(GameAction::SelectCards { cards: pick }).unwrap();
            }
            _ => {
                if runner.act(GameAction::PassPriority).is_err() {
                    break;
                }
            }
        }
    }

    assert_eq!(p1p1(&runner, cultivator), 1);
}

Two-sided controls, each failing its own half: dropping the parser arm fails the parse, the not-unimplemented and the counter assertions; dropping the PlayerPerformedAction emission fails only the counter assertion.

Logs/screenshots/game-state

Turn-13 state dump available on request; the relevant extract is quoted above.


Diagnosed with an LLM (claude-opus-5). No PR opened: CONTRIBUTING.md routes crates/engine/ changes through /engine-implementer, which this session did not run.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions