Skip to content

fix(engine): treat a tracked-set-scaled sacrifice count as a set consumer#6191

Open
tryeverything24 wants to merge 2 commits into
phase-rs:mainfrom
tryeverything24:fix/issue-5991
Open

fix(engine): treat a tracked-set-scaled sacrifice count as a set consumer#6191
tryeverything24 wants to merge 2 commits into
phase-rs:mainfrom
tryeverything24:fix/issue-5991

Conversation

@tryeverything24

@tryeverything24 tryeverything24 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #5991.

Malfegor: "Flying. When Malfegor enters the battlefield, discard your hand, then each opponent sacrifices a creature of their choice for each card discarded this way."

Root cause & fix

The compound trigger parses correctly into a DiscardCard producer chained to a Sacrifice sub-ability whose count reads FilteredTrackedSetSize { caused_by: Discarded } — the standard "for each X discarded/exiled/milled this way" idiom already used elsewhere in the engine (Kylox's ExileTop, Seasoned Pyromancer's repeat_for).

effect_references_tracked_set(), which decides whether a preceding producer effect needs to publish its affected object ids into the chain-scoped tracked set before a downstream consumer reads it, had no arm for Effect::Sacrifice. So the discard step's affected ids were never published, and the sacrifice count's FilteredTrackedSetSize read an empty or stale set and resolved to 0 — Malfegor's opponent was never asked to sacrifice anything, a fully-parsed card that silently does nothing.

Fixed by adding a Sacrifice { count, .. } => quantity_hits_tracked(count) arm, mirroring the existing ExileTop arm immediately above it.

Testing

New end-to-end test driving the real DiscardCardSacrifice resolution chain via resolve_ability_chain (the same production entry point every triggered ability resolves through), using the parser's own parse_effect_chain output, not a hand-built ResolvedAbility. Discriminating scenario: controller discards a 2-card hand, opponent controls 3 creatures — must be asked to sacrifice exactly 2 (not a fixed 1, not 0, the two most plausible pre-fix symptoms).

Verified locally: fmt clean, clippy clean (-D warnings), 3545 integration tests pass (3544 pre-existing + 1 new), 0 regressions.

Summary by CodeRabbit

  • Bug Fixes

    • Improved tracked-set detection across all effect-embedded quantity expressions, including token “enter with counters” scenarios and chained ability behavior.
    • Fixed Malfegor’s “discard your hand, then each opponent sacrifices a creature” so opponents are prompted to sacrifice the exact required number.
    • Ensured no sacrifice prompt appears when the discarded hand is empty.
  • Tests

    • Added an integration regression suite for Malfegor discard-to-sacrifice, including tracked-set counter sizing across chained effects and the empty-hand case.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue #5991 by adding Effect::Sacrifice to effect_references_tracked_set in the engine, ensuring that sacrifice counts scaled by a tracked set (such as Malfegor's ETB ability) resolve correctly. It also introduces comprehensive integration tests to verify this behavior. The reviewer's feedback highlights incorrect MTG Comprehensive Rules (CR) citations in both the engine and test comments, providing corrected rules (such as CR 608.2g and CR 701.21c), and suggests investigating whether other similar effects require sibling coverage to prevent similar bugs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/engine/src/game/effects/mod.rs Outdated
Comment on lines +3761 to +3771
// CR 608.2c + CR 701.21a + CR 701.17a: a sacrifice count that reduces
// the chain tracked set is a CONSUMER of that set (Malfegor: "each
// opponent sacrifices a creature of their choice for each card
// discarded this way", issue #5991). Same class as the `ExileTop`
// arm above (Kylox) and the `repeat_for` check below (Seasoned
// Pyromancer, #740): without this arm, the preceding discard/
// destroy/mill never published its affected ids, so
// `FilteredTrackedSetSize { caused_by: Discarded }` read an empty or
// stale set and resolved to 0 — a fully-supported-looking card that
// forces no sacrifice at all.
Effect::Sacrifice { count, .. } => quantity_hits_tracked(count),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The CR citations in the comment are slightly incorrect or imprecise:

  • CR 608.2c describes choices from non-public zones, whereas sacrifice is a choice from the battlefield (a public zone). The correct rule for determining information from the game on resolution is CR 608.2g.
  • CR 701.21a is the general definition of discard, but CR 701.21c is the specific rule governing "discarded this way" references.

Additionally, per the Sibling coverage (L2) lens, please check if other effects that accept a count or quantity (such as Effect::DrawCard, Effect::Damage, Effect::GainLife, Effect::LoseLife, Effect::Mill, Effect::PutCounters, etc.) also need to be added to effect_references_tracked_set to prevent similar bugs when they are scaled by a tracked set.

Suggested change
// CR 608.2c + CR 701.21a + CR 701.17a: a sacrifice count that reduces
// the chain tracked set is a CONSUMER of that set (Malfegor: "each
// opponent sacrifices a creature of their choice for each card
// discarded this way", issue #5991). Same class as the `ExileTop`
// arm above (Kylox) and the `repeat_for` check below (Seasoned
// Pyromancer, #740): without this arm, the preceding discard/
// destroy/mill never published its affected ids, so
// `FilteredTrackedSetSize { caused_by: Discarded }` read an empty or
// stale set and resolved to 0 — a fully-supported-looking card that
// forces no sacrifice at all.
Effect::Sacrifice { count, .. } => quantity_hits_tracked(count),
// CR 608.2g + CR 701.21c + CR 701.17a: a sacrifice count that reduces
// the chain tracked set is a CONSUMER of that set (Malfegor: "each
// opponent sacrifices a creature of their choice for each card
// discarded this way", issue #5991). Same class as the ExileTop
// arm above (Kylox) and the repeat_for check below (Seasoned
// Pyromancer, #740): without this arm, the preceding discard/
// destroy/mill never published its affected ids, so
// FilteredTrackedSetSize { caused_by: Discarded } read an empty or
// stale set and resolved to 0 — a fully-supported-looking card that
// forces no sacrifice at all.
Effect::Sacrifice { count, .. } => quantity_hits_tracked(count),
References
  1. Rule R6 requires every rules-touching line of engine code to carry a verified CR annotation of the form CR <number>: <description>. (link)

Comment on lines +115 to +120
/// CR 701.9a + CR 701.17a + CR 608.2c: P0 (the caster) discards a 2-card hand
/// (a mandatory whole-hand discard, no choice needed since hand size ==
/// discard count), then P1 (the lone opponent, controlling 3 creatures) must
/// sacrifice exactly 2 of them — the discarded-card count, not a fixed 1 and
/// not 0. This is the exact shape reported in #5991: "forces the opponent to
/// sacrifice their creatures after I discard my hand."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The CR citations in this test comment are incorrect:

  • CR 701.9a is about drawing a card ("To draw a card, a player moves the top card..."), but the action here is discarding. It should be CR 701.21c (or CR 701.21a).
  • CR 608.2c is about non-public zone choices, but sacrifice is from the battlefield (public zone). It should be CR 608.2g.
Suggested change
/// CR 701.9a + CR 701.17a + CR 608.2c: P0 (the caster) discards a 2-card hand
/// (a mandatory whole-hand discard, no choice needed since hand size ==
/// discard count), then P1 (the lone opponent, controlling 3 creatures) must
/// sacrifice exactly 2 of them — the discarded-card count, not a fixed 1 and
/// not 0. This is the exact shape reported in #5991: "forces the opponent to
/// sacrifice their creatures after I discard my hand."
/// CR 701.21c + CR 701.17a + CR 608.2g: P0 (the caster) discards a 2-card hand
/// (a mandatory whole-hand discard, no choice needed since hand size ==
/// discard count), then P1 (the lone opponent, controlling 3 creatures) must
/// sacrifice exactly 2 of them — the discarded-card count, not a fixed 1 and
/// not 0. This is the exact shape reported in #5991: "forces the opponent to
/// sacrifice their creatures after I discard my hand."

@matthewevans matthewevans self-assigned this Jul 19, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer follow-up: corrected the verified sacrifice citation (CR 701.21a) and routed tracked-set count/amount detection through the existing Effect::count_expr() authority, retaining the special token P/T and planar-deck fields. The Malfegor end-to-end regression remains in place. Held for fresh CI and parse-diff evidence on 2211732.

@matthewevans matthewevans removed their assignment Jul 19, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Jul 19, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — the tracked-set consumer walk is incomplete.

🔴 Blocker

effect_references_tracked_set now treats Effect::count_expr() as though it covers every quantity-bearing field, but count_expr is documented as the primary count/amount accessor (crates/engine/src/types/ability.rs:14399-14403). Effect::Token also evaluates each enter_with_counters quantity during resolution (crates/engine/src/types/ability.rs:9939-9975, crates/engine/src/game/effects/token.rs:2039-2043), while the current special case only examines token power/toughness (crates/engine/src/game/effects/mod.rs:3740-3745).

A producer followed by a token effect with fixed count and P/T but enter_with_counters: [(+1/+1, TrackedSetSize)] is consequently classified as non-consuming; the producer does not publish its set and the counter quantity resolves incorrectly. Other secondary quantity fields have the same structural risk, so adding only this one field would preserve a misleading generic invariant.

Please replace the primary-count shortcut with an exhaustive, authoritative traversal of every QuantityExpr carried by an effect (or an equivalent complete abstraction), and add a runtime regression covering tracked entry counters. The existing Malfegor sacrifice test remains useful but does not exercise this missing path.

✅ Clean

The sacrifice case is correctly identified as a tracked-set consumer, and the existing test drives the real discard/sacrifice pipeline.

Recommendation: rework the quantity traversal, add the entry-counter regression, then request re-review.

@matthewevans matthewevans added the bug Bug fix label Jul 19, 2026
@matthewevans matthewevans removed their assignment Jul 19, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — the current head still has the incomplete tracked-set traversal.

🔴 Blocker

effect_references_tracked_set says it walks every quantity position, but its Effect::Token arm only inspects count, power, and toughness at effects/mod.rs:3912-3963. Effect::Token also carries enter_with_counters: Vec<(CounterType, QuantityExpr)> (ability.rs:9996-10000), and the production token resolver evaluates every such quantity (token.rs:2045-2051).

A producer followed by a fixed-count/fixed-P/T token whose entry-counter quantity is TrackedSetSize is therefore classified as non-consuming; the producer does not publish its set and the token receives zero counters. The added Malfegor test only exercises Effect::Sacrifice (issue_5991_malfegor_discard_sacrifice.rs:115-204), so it cannot detect that sibling path.

Please use an exhaustive authority that visits every QuantityExpr carried by an effect (including nested token entry counters), then add a production-chain regression with a tracked-set-scaled entry counter. count_expr() alone is insufficient because it intentionally exposes only the primary count/amount slot.

🟡 Non-blocking

The CR citations at effects/mod.rs:3942 and issue_5991_malfegor_discard_sacrifice.rs:115 cite CR 701.17a, which is the mill rule; sacrifice is CR 701.21a and discard is CR 701.9a (verified in docs/MagicCompRules.txt:3327-3331,3449-3451). Please remove the unrelated citation or replace it with the rule the annotated behavior implements.

Recommendation: complete the quantity traversal and its discriminating runtime test, correct the citations, then request re-review.

@tryeverything24

Copy link
Copy Markdown
Contributor Author

Addressed both review points:

Exhaustive quantity traversal: replaced the enumerated primary-count match in effect_references_tracked_set with a new exhaustive authority, Effect::for_each_quantity_expr (types/ability.rs), which visits every QuantityExpr an effect directly carries — including the secondary slots count_expr() doesn't expose (Token::enter_with_counters, Dig::keep_count_expr, PutSticker::max_ticket_cost, dynamic PtValue::Quantity P/T, ChangeZone's conditional entry counters, ForEachCategory per-member counts, ...) — and recurses through variants embedding another Effect/ResolvedAbility. The match is exhaustive with no wildcard arm (same convention as count_expr()/target_filter()), so any newly added variant must be explicitly classified rather than silently reporting no quantities. Deferred definition payloads (AbilityDefinition, ContinuousModification, ...) are documented as out of scope: their quantities evaluate in their own later resolution context, and current-chain consumption there is signalled by explicit flags (CreateDelayedTrigger { uses_tracked_set }).

Tests: added a matcher-boundary unit test (a FIXED-count/FIXED-P/T token whose only tracked-set reference is a TrackedSetSize entry counter must classify as consuming) and a production-chain regression through resolve_ability_chain (whole-hand discard of 2 → token enters with exactly 2 +1/+1 counters; zero counters is the pre-fix failure).

Citations: removed the mill rule (CR 701.17a); the comments now cite CR 701.9a (discard) and CR 701.21a (sacrifice).

Verified locally in an isolated target dir: fmt clean, workspace clippy -D warnings clean, engine lib 17516 passed, engine integration 3751 passed. Rebased onto current main.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fc75b09b-bd3e-4c5c-951f-db924c6aeaa1

📥 Commits

Reviewing files that changed from the base of the PR and between cf6844c and 610d2ab.

📒 Files selected for processing (4)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs
  • crates/engine/src/types/ability.rs

📝 Walkthrough

Walkthrough

The effect quantity visitor now covers directly carried quantity expressions, and tracked-set detection uses it. Integration tests validate Malfegor’s discard-scaled sacrifice, empty-hand behavior, and tracked-set-driven token counters.

Changes

Tracked-set quantity flow

Layer / File(s) Summary
Exhaustive quantity-expression visitor
crates/engine/src/types/ability.rs
for_each_quantity_expr traverses quantity-bearing effect fields and nested resolved ability branches while excluding deferred payloads.
Tracked-set detection integration
crates/engine/src/game/effects/mod.rs
Tracked-set detection scans expressions through the unified visitor, removes the PtValue helper, and tests token entry counters using TrackedSetSize.
Malfegor resolution regressions
crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs, crates/engine/tests/integration/main.rs
Integration tests verify discard-scaled sacrifice prompts, empty-hand behavior, and token counters propagated from discarded cards; the module is registered in the test harness.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: matthewevans, lgray, andriypolanski, mcbradd

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main fix: tracked-set-scaled sacrifice counts are now treated as set consumers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs (1)

209-235: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No reach-guard that the discard step actually executed with zero cards discarded.

malfegor_empty_hand_discard_causes_no_sacrifice only asserts the absence of a sacrifice prompt. Since the hand starts empty, there's no independent evidence (e.g. inspecting events for a discard-count-zero event, or asserting on the tracked-set size directly) that the discard effect was reached and correctly computed a 0, as opposed to the whole chain having short-circuited before even evaluating the discard/tracked-set-publish step for an unrelated reason. The events vector is captured but never inspected.

As per path instructions, "For every negative assertion ... require a paired positive reach-guard proving the input actually reached the code under test ... an upstream short-circuit makes a negative pass for the wrong reason."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs`
around lines 209 - 235, Strengthen
malfegor_empty_hand_discard_causes_no_sacrifice with a positive reach-guard
proving the empty discard step executed and computed zero discarded cards.
Inspect the captured events or the relevant tracked-set state after
resolve_ability_chain, using the existing event/state symbols, while preserving
the current assertions that no creatures are sacrificed and no sacrifice prompt
appears.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs`:
- Around line 205-207: Replace the incorrect CR 107.3a citation in the
negative/no-regression guard comment with a rule citation whose verified text in
docs/MagicCompRules.txt supports choosing zero, such as CR 107.1c if confirmed.
Keep the explanation that an empty hand produces zero sacrifices and preserves
the legal no-op behavior.

---

Nitpick comments:
In `@crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs`:
- Around line 209-235: Strengthen
malfegor_empty_hand_discard_causes_no_sacrifice with a positive reach-guard
proving the empty discard step executed and computed zero discarded cards.
Inspect the captured events or the relevant tracked-set state after
resolve_ability_chain, using the existing event/state symbols, while preserving
the current assertions that no creatures are sacrificed and no sacrifice prompt
appears.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c0f4d864-a4eb-44b9-b46c-e3b69f6af5bb

📥 Commits

Reviewing files that changed from the base of the PR and between d568002 and 7539181.

📒 Files selected for processing (4)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs
  • crates/engine/tests/integration/main.rs

Comment on lines +205 to +207
/// Negative/no-regression guard: an empty hand discards nothing, so the
/// tracked set is empty and the opponent must sacrifice 0 creatures — a
/// legal no-op (CR 107.3a), not an erroneous fixed-1 sacrifice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

CR 107.3a citation doesn't describe this behavior.

CR 107.3a governs how a player chooses/announces the value of X in a mana/activation cost — it has nothing to do with a zero-count sacrifice being a legal no-op. This citation doesn't support the claim it's attached to; it looks like a nearby/adjacent rule number was picked without verifying the rule body (the failure mode CLAUDE.md explicitly warns about with CR 119/120/121 and 701.x mix-ups). CR 107.1c ("may choose any number, including zero") is closer to what's actually intended here, but should be verified against docs/MagicCompRules.txt before use.

As per path instructions, "a CR citation whose rule body does not describe the code" must be flagged, and CR numbers must be verified via docs/MagicCompRules.txt before being added.

Suggested correction
-/// Negative/no-regression guard: an empty hand discards nothing, so the
-/// tracked set is empty and the opponent must sacrifice 0 creatures — a
-/// legal no-op (CR 107.3a), not an erroneous fixed-1 sacrifice.
+/// Negative/no-regression guard: an empty hand discards nothing, so the
+/// tracked set is empty and the opponent must sacrifice 0 creatures — a
+/// legal no-op (CR 107.1c: a chosen/derived number may be zero), not an
+/// erroneous fixed-1 sacrifice.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Negative/no-regression guard: an empty hand discards nothing, so the
/// tracked set is empty and the opponent must sacrifice 0 creatures — a
/// legal no-op (CR 107.3a), not an erroneous fixed-1 sacrifice.
/// Negative/no-regression guard: an empty hand discards nothing, so the
/// tracked set is empty and the opponent must sacrifice 0 creatures — a
/// legal no-op (CR 107.1c: a chosen/derived number may be zero), not an
/// erroneous fixed-1 sacrifice.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs`
around lines 205 - 207, Replace the incorrect CR 107.3a citation in the
negative/no-regression guard comment with a rule citation whose verified text in
docs/MagicCompRules.txt supports choosing zero, such as CR 107.1c if confirmed.
Keep the explanation that an empty hand produces zero sacrifices and preserves
the legal no-op behavior.

Source: Path instructions

@matthewevans matthewevans self-assigned this Jul 22, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — for_each_quantity_expr is still incomplete on 7539181.

🔴 Blocker

The new authority says it visits every QuantityExpr, but it classifies Effect::Mana as quantity-free at crates/engine/src/types/ability.rs:14961. ManaProduction carries live count: QuantityExpr slots (for example Colorless at :1877-1880, AnyOneColor at :1888-1890, and the other dynamic-production variants), and game/effects/mana.rs:454-480 resolves them during this effect's resolution. A producer followed by one of those consumers still fails to publish its tracked set.

Two more live nested carriers have the same hole: Effect::PayCost visits only scale at ability.rs:14772-14776 while its AbilityCost contains dynamic values such as ManaDynamic, PayLife, Discard, PayEnergy, and PaySpeed (:8156-8175, :8251-8257); and Effect::RollDie visits count but not modifier (:14792-14793), though DieRollModifier::{Add,Subtract} holds a QuantityExpr (:1099-1103) resolved in game/effects/roll_die.rs:73-79.

Please make the traversal genuinely compositional and exhaustive across these nested domain types (including recursive cost forms such as Composite, OneOf, EffectCost, and PerCounter where their quantities resolve in the current chain), then add discriminating production-chain coverage for the newly traversed live seams. This is the same tracked-set-publication defect class the PR is intended to remove, so the current "exhaustive" contract is not safe to merge.

🟡 Non-blocking follow-ups

The current CodeRabbit review correctly flags that the empty-hand regression at issue_5991_malfegor_discard_sacrifice.rs:205-234 has no positive reach guard and carries an unrelated CR 107.3a citation. Please either add a direct event/state assertion proving the empty discard step ran, and replace the citation only after verifying it locally, or remove the unsupported citation.

✅ Clean

The Malfegor discard-to-sacrifice test and the token entry-counter regression both drive the relevant resolution chain and would catch their respective prior shortcuts. The current parse-diff sticky is no_changes for this head, matching the non-parser scope.

Recommendation: complete the nested quantity traversal and its regressions, address the small test follow-up, then request re-review.

@matthewevans matthewevans removed their assignment Jul 22, 2026
@matthewevans

Copy link
Copy Markdown
Member

Correction to the blocker line reference: AbilityCost::PayEnergy / PaySpeed are at crates/engine/src/types/ability.rs:8244-8249 on this head (the previously cited :8251-8257 range begins ReturnToHand). The finding is unchanged.

@tryeverything24

Copy link
Copy Markdown
Contributor Author

Rebased onto current main to clear the merge conflict introduced by the #6193 merge (both added a mod line to tests/integration/main.rs at the same anchor — kept both). No content changes to the reviewed implementation. Full local gate re-verified post-rebase in an isolated target dir: fmt clean, workspace clippy -D warnings clean, engine lib 17529 passed, engine integration 3756 passed.

@matthewevans matthewevans self-assigned this Jul 22, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — the new exhaustive visitor still omits direct QuantityExpr carriers.

🔴 Blocker

[HIGH] Effect::for_each_quantity_expr is incomplete despite its exhaustive-contract documentation. Evidence: crates/engine/src/types/ability.rs:14961 groups Effect::Mana { .. } with no quantities, while ManaProduction directly carries count: QuantityExpr (for example Colorless at :1878-1881, AnyOneColor at :1888-1891, and AnyOneColorAmongPermanents at :2005-2008). Effect::PayCost at :14772-14775 walks only scale, dropping dynamic AbilityCost fields (ManaDynamic :8156-8158, PayLife :8170-8172, Discard :8173-8175, PayEnergy/PaySpeed :8260-8266). Effect::RollDie at :14792-14794 walks only count, although modifier contains DieRollModifier::{Add,Subtract} values at :1099-1103 and RollDie carries that modifier at :11861-11868. Why it matters: a tracked-set producer can fail to publish before any of these downstream consumers, yielding a zero or incorrect resolution; the visitor's claimed exhaustive contract would then silently regress as these variants evolve. Suggested fix: compose dedicated exhaustive visitors for ManaProduction, AbilityCost, and DieRollModifier, and add focused regressions proving the tracked-set classifier reaches each missing carrier.

Recommendation: request changes — complete the visitor and its regression matrix before re-review.

@matthewevans matthewevans removed their assignment Jul 22, 2026
…umer

Fixes phase-rs#5991.

Malfegor: "Flying. When Malfegor enters the battlefield, discard your
hand, then each opponent sacrifices a creature of their choice for
each card discarded this way."

The compound trigger parses correctly into a DiscardCard producer
chained to a Sacrifice sub-ability whose count reads
FilteredTrackedSetSize { caused_by: Discarded } -- the standard
"for each X discarded/exiled/milled this way" idiom already used
elsewhere in the engine (Kylox's ExileTop, Seasoned Pyromancer's
repeat_for).

effect_references_tracked_set(), which decides whether a preceding
producer effect needs to publish its affected object ids into the
chain-scoped tracked set before a downstream consumer reads it, had no
arm for Effect::Sacrifice. So the discard step's affected ids were
never published, and the sacrifice count's FilteredTrackedSetSize read
an empty or stale set and resolved to 0 -- Malfegor's opponent was
never asked to sacrifice anything, a fully-parsed card that silently
does nothing.

Fixed by adding a Sacrifice { count, .. } => quantity_hits_tracked(count)
arm, mirroring the existing ExileTop arm immediately above it.

New end-to-end test driving the real DiscardCard -> Sacrifice
resolution chain via resolve_ability_chain (the same production entry
point every triggered ability resolves through), using the parser's
own parse_effect_chain output, not a hand-built ResolvedAbility.
Discriminating scenario: controller discards a 2-card hand, opponent
controls 3 creatures -- must be asked to sacrifice exactly 2 (not a
fixed 1, not 0, the two most plausible pre-fix symptoms).

Verified locally: fmt clean, clippy clean (-D warnings), 3545
integration tests pass (3544 pre-existing + 1 new), 0 regressions.
…lassification

Review follow-up (phase-rs#5991): effect_references_tracked_set's enumerated
quantity match only saw a handful of primary count/amount slots — in
particular Effect::Token's enter_with_counters entry-counter quantities
were invisible, so a producer chained into a fixed-count/fixed-P/T token
entering "with a +1/+1 counter for each card <verbed> this way" was
classified non-consuming, the producer never published its set, and the
token entered with zero counters.

Replaced the shortcut with Effect::for_each_quantity_expr, a new
exhaustive authority (types/ability.rs) that visits EVERY QuantityExpr an
effect directly carries — all secondary slots (enter_with_counters,
keep_count_expr, max_ticket_cost, dynamic PtValue power/toughness,
conditional entry counters, ForEachCategory per-member counts, ...) —
and recurses through variants embedding another Effect/ResolvedAbility.
The match is exhaustive with no wildcard arm (same convention as
count_expr()/target_filter()), so a newly added variant must be
explicitly classified. Deferred definition payloads (AbilityDefinition,
ContinuousModification, ...) are documented as out of scope: their
quantities evaluate in their own later resolution context, with current-
chain consumption signalled by explicit flags (CreateDelayedTrigger's
uses_tracked_set).

Also per review: corrected the CR citations (sacrifice is CR 701.21a,
discard CR 701.9a — CR 701.17a is the mill rule and was removed), and
added both a matcher-boundary unit test (tracked entry counter on an
otherwise-fixed token marks the ability as consuming) and a production-
chain regression (discard 2 -> token enters with 2 +1/+1 counters via
resolve_ability_chain).

Verified in an isolated CARGO_TARGET_DIR: cargo fmt --all --check clean;
cargo clippy --workspace --exclude phase-tauri --all-targets --features
engine/proptest -D warnings clean; engine lib 17516 passed; engine
integration 3751 passed.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — the new exhaustive quantity visitor is still incomplete on 610d2abe839c6134bd93c37859b9e28155edd14f.

🔴 Blocker

[HIGH] The new Effect::for_each_quantity_expr still drops live nested quantity carriers. Evidence: crates/engine/src/types/ability.rs:14961 groups Effect::Mana as quantity-free although its ManaProduction owns dynamic count values; :14772-14776 visits only PayCost::scale and not its AbilityCost; :14792-14794 visits only RollDie::count and not dynamic DieRollModifier::{Add,Subtract} quantities. Why it matters: a preceding producer will still fail to publish its tracked set for those current-chain consumers, so their tracked quantity resolves as zero or wrong despite the visitor’s exhaustive contract. Suggested fix: compose exhaustive visitors for ManaProduction, recursive AbilityCost (including nested forms), and DieRollModifier, invoke them from Effect, and add production-chain regressions for each seam.

🟡 Non-blocking

[MED] The empty-hand regression is a vacuous negative check and retains an unsupported CR citation. Evidence: crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs:205-234 only asserts no prompt/no battlefield change and never checks the captured resolution events; :207 cites CR 107.3a for the zero-count no-op. Why it matters: an upstream short-circuit that skips the discard/tracked-set path still passes. Suggested fix: add a discard-resolution reach guard, then verify a replacement citation against the local Comprehensive Rules file or remove it.

✅ Clean

The current parse-diff sticky comment reports no card-parse changes, which matches this engine-only change. I also rechecked the current CodeRabbit findings; they identify the same remaining incomplete-traversal and test/citation issues.

Recommendation: request changes — complete the nested visitor composition and add discriminating production-path tests before re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Malfegor — Its ability that forces the opponent to sacrifice their creatures after I discard my hand is malfunctioning

2 participants