fix(engine): activate a zero-card "discard your hand" cost with an empty hand (Lion's Eye Diamond)#6596
Conversation
…g (Lion's Eye Diamond)
Lion's Eye Diamond ("Discard your hand, Sacrifice this artifact: Add three
mana of any one color") could not be activated with an empty hand: the cost
surfacing paths emitted a dead WaitingFor::PayCost { Discard, count: 0,
choices: [] } and stalled, even though legal_actions/cost_payability correctly
offered the ability (0 >= 0). Discarding your hand with no cards is a cost
paid by doing nothing (CR 601.2h; CR 701.9a moves cards only when cards exist).
Class-level fix via a single authority: a resolved-count-0 non-self FromHand
discard leg is treated as paid and the next unpaid leg (the sacrifice) is
surfaced instead of a dead prompt. New resolve_non_self_discard_requirement in
game/casting.rs (tri-state: auto-paid None / unpayable Err / interactive Some)
is applied at both activation surfacing sites; discard_cost_choice in
game/mana_abilities.rs skips count 0 mirroring its exile-cost sibling.
Covers Lion's Eye Diamond, Diamond Lion (mana-ability path) and Bomat Courier
(activated-ability path). Fixed Discard{1} on an empty hand stays unpayable
(guard fires only on resolved count 0). Pure runtime change; zero parse-diff.
Closes phase-rs#6494
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe change centralizes non-self FromHand discard resolution, treats resolved zero-card requirements as satisfied, and updates casting and mana-ability payment flows to skip zero-count discard prompts. Regression tests cover eligibility, selection mode, empty hands, and subsequent cost resolution. ChangesDiscard cost resolution
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/engine/src/game/mana_abilities.rs (1)
3566-3586: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winShare the discard-leg resolver instead of keeping a second detector.
discard_cost_choicestill re-derives the same non-self discard shape locally, and its extraselection: CardSelectionMode::Chosengate makes it diverge fromcasting::find_non_self_discardrather than reuse it. Fold that selection constraint into a shared helper or adapter so the zero-count and payability rules stay aligned in one place.🤖 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/src/game/mana_abilities.rs` around lines 3566 - 3586, Update discard_cost_choice to reuse the shared casting::find_non_self_discard resolver instead of independently calling find_non_self_discard_cost and applying a separate selection gate. Move the CardSelectionMode::Chosen constraint into the shared helper or an adapter, preserving the existing zero-count handling and ensuring discard payability rules remain aligned in one place.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.
Nitpick comments:
In `@crates/engine/src/game/mana_abilities.rs`:
- Around line 3566-3586: Update discard_cost_choice to reuse the shared
casting::find_non_self_discard resolver instead of independently calling
find_non_self_discard_cost and applying a separate selection gate. Move the
CardSelectionMode::Chosen constraint into the shared helper or an adapter,
preserving the existing zero-count handling and ensuring discard payability
rules remain aligned in one place.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 24e2fdc3-e3c0-4e60-a746-954001acee1e
📒 Files selected for processing (4)
crates/engine/src/game/casting.rscrates/engine/src/game/casting_costs.rscrates/engine/src/game/casting_tests.rscrates/engine/src/game/mana_abilities.rs
Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the claimed discard-cost authority is duplicated.
🔴 Blocker
[MED] Make the casting resolver/detector the sole authority for FromHand discard costs. Evidence: crates/engine/src/game/casting.rs:15243-15296 introduces find_non_self_discard/a resolver, while game/mana_abilities.rs:3566-3585,3683-3696 retains a parallel detector with a different CardSelectionMode::Chosen constraint. The two paths can diverge as cost forms grow. Reuse the casting authority, parameterizing the mana-specific selection path only if needed, and add a shared-seam test.
Recommendation: request changes; consolidate the detector before approval.
…t authority (phase-rs#6494 review) Address matthewevans review on phase-rs#6596: consolidate the duplicate mana-ability discard detector (find_non_self_discard_cost) into casting::find_non_self_discard (now returns the CardSelectionMode) and route discard_cost_choice through the shared resolve_non_self_discard_requirement, so the zero-count + payability rules live in one authority. The mana path keeps only its Chosen-selection policy as an explicit call-site gate. Behavior-preserving; adds a shared-seam test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — consolidated onto the single casting authority (head What changed
Shared-seam test (as requested): Behavior-preserving: all 7 prior tests green with unchanged assertions; |
matthewevans
left a comment
There was a problem hiding this comment.
Approved: current head consolidates FromHand discard detection at the casting authority, correctly treats a resolved zero-card discard as paid, and retains discriminating runtime coverage for both mana and activated-ability paths.
Tier: Frontier
Model: claude-opus-4-8
Closes #6494.
Summary
Lion's Eye Diamond — "Discard your hand, Sacrifice this artifact: Add three mana of any one color. Activate only as an instant." — could not be activated with an empty hand. Discarding your hand with no cards is a cost paid by doing nothing (CR 601.2h — a zero-card cost is not unpayable, it is trivially paid; CR 701.9a moves cards hand→graveyard only when cards exist), so LED is activatable empty-handed — its canonical competitive use (crack it in response holding zero cards for three mana). Instead the activation stalled forever on a dead
WaitingFor::PayCost { kind: Discard, count: 0, choices: [], min_count: 0 }.Root cause is runtime, not the parser — LED's cost parses faithfully as
Composite[ Discard { count: HandSize(Controller) }, Sacrifice(SelfRef) ].legal_actionscorrectly offered LED (cost_payability.rscomputes0 >= 0→ payable), but the interactive cost-surfacing paths resolved the sameHandSize = 0and unconditionally emitted aPayCost { Discard, count: 0 }— a prompt with nothing to select. Submitting the empty selection left the "not yet paid" sentinel unchanged and re-entered the same surfacing, re-emitting the dead prompt: an infinite stall. The divergence: payability short-circuitscount == 0, the surfacing paths did not — unlike the sibling zero-quantity cost paths (e.g.mana_abilities::exile_cost_choice).Class-level fix, not an LED special case: a resolved-count-0 non-self "discard from hand" leg is paid by doing nothing and the engine surfaces the next unpaid leg (here, the sacrifice) instead of a dead prompt. This is applied through a single shared authority so the mana-ability path and the activated-ability path resolve the same rule:
resolve_non_self_discard_requirement(game/casting.rs) — the tri-state authority for a non-selfFromHanddiscard leg:Ok(None)(no such leg, or resolved count 0 → auto-paid),Err(fewer eligible cards than the required nonzero count → CR 601.2h unpayable),Ok(Some((count, eligible)))(interactive selection). It replaces the duplicated detect→resolve→error→PayCostblocks at the two activation cost-surfacing sites.discard_cost_choice(game/mana_abilities.rs, the LED/Diamond Lion mana-ability path) returnsNoneon a resolved count of 0, mirroring thecount == 0skip its exile sibling already uses — the discard leg is skipped and the self-sacrifice + mana proceed toChooseManaColor.Class members fixed: Lion's Eye Diamond and Diamond Lion (mana-ability path), Bomat Courier ("{R}, Discard your hand, Sacrifice Bomat Courier: …", activated-ability path) — each now activatable with an empty hand. A fixed
Discard { 1 }("Discard a card") on an empty hand is unchanged: it resolves count 1,0 >= 1is false, so it stays unpayable and is never offered — the guard fires only on a resolved count of 0, never oncount >= 1.Deliberately out of scope
The spell additional-cost discard arm (
pay_additional_cost_with_source) is intentionally left unchanged. A genuine "discard your hand" spell additional cost resolves to ≥1 while the spell is still in hand, so it never reaches count 0. The only real cards that reach that arm with count 0 are Firestorm / Abandon Hope / Nahiri's Wrath, whose "discard X cards" additional cost currently misparses toDiscard { Fixed(0) }while the correct count is X — auto-paying there would silently cast them without the discard, masking a parser bug as a rules-incorrect free cast. ThatDiscard { Fixed(0) }misparse of "discard X cards" is a separate parser issue and should be fixed at the parser seam; I did not paper over it at the cost-resolution layer. (Happy to file/take that as a follow-up.)Implementation method (required)
Method: /engine-implementer — plan (/engine-planner) → /review-engine-plan (adversarial; corrected the seam to the mana-ability
discard_cost_choice, tightened the class to resolved-count-0 only, and named the real corpus cards) → implement (tests-first RED→GREEN) → /review-impl (Maintainer-Simulation Gate; it flagged the spell-additional-cost arm as a rules-incorrect Firestorm-misparse mask — that arm and its synthetic test were reverted, leaving the two genuinely-reachable seams). Each step in a fresh agent context. Final read-only /review-impl on the retained diff: PASS.Gate A
./scripts/check-parser-combinators.sh→ Gate A PASS head=d633cf026 (Gate G PASS). No files undercrates/engine/src/parser/are touched — the change is pure runtime cost-resolution.Anchored on
game/mana_abilities.rsexile_cost_choice— the interactive non-self cost sibling in the same mana-ability surfacing prefix;discard_cost_choicenow applies the identical resolved-count-0 skip, so a zero-quantity leg is treated as paid and the next leg (the self-sacrifice incontinue_mana_ability_cost_payment) is surfaced.game/cost_payability.rsDiscard arm — the payability side already short-circuitscount == 0(0 >= 0→ offered); this change brings the surfacing side into agreement, closing the payable-but-un-surfaceable gap that caused the stall.game/casting.rsfind_non_self_discard/find_eligible_discard_targets— the existingFromHanddiscard-leg detector + eligible-card query, now composed once insideresolve_non_self_discard_requirementand reused at both activation surfacing sites (surface_next_unpaid_interactive_activation_costandhandle_activate_ability'sbegin_cost_payment).Verification
cargo fmt --allclean;cargo clippy -p engine --tests0 warnings;cargo test -p engine --lib17,631 passed / 0 failed.activate_mana_ability,handle_activate_ability— with verbatim Oracle text; LED/Diamond Lion are mana abilities and do not use the stack):lions_eye_diamond_activates_empty_handed_and_produces_chosen_color— empty hand → result isChooseManaColor(notPayCost{Discard}); LED is sacrificed (sole card in graveyard);handle_choose_mana_color(Red)→ exactly 3 Red mana. The reported bug; revert-guard.diamond_lion_activates_empty_handed_and_produces_chosen_color— same class, second real card (proves class-not-card).lions_eye_diamond_discards_hand_and_then_produces_chosen_color(existing control) — 2 cards in hand → still surfacesPayCost{Discard,count:2}, both discarded, LED sacrificed, 3 mana (guard: thecount == 0skip must not over-fire oncount > 0).bomat_courier_activates_empty_handed_casting_path— Bomat Courier's verbatim ability, empty hand → no deadPayCost{Discard}, self-sacrifice fires, ability resolves.resolve_discard_requirement_fixed_one_empty_hand_is_unpayable_err— FixedDiscard{1}+ empty hand →Err(unpayable), notOk(None); reach-guarded with a card in hand →Ok(Some((1,[card]))).resolve_discard_requirement_fixed_two_with_three_eligible_offers_all—Discard{2}+ 3 eligible →Ok(Some((2, 3 choices)))(interactive prompt still built for count > 0).resolve_discard_requirement_source_card_scope_is_not_auto_paid— aSourceCard"discard this card" cost isFromHand-excluded (helper returnsNone, count resolves to 1) so it can never hit zero-count auto-pay; reach-guarded that theFromHandshape is detected.discard_cost_choiceresolved == 0skip flips the LED/Diamond Lion tests (left: PayCost { Discard, count: 0, choices: [] },right: ChooseManaColor); reverting the helper'scount == 0skip flips the Bomat test (a deadPayCost{Discard}is surfaced on the casting path). The control and boundary tests stay green across both reverts.docs/MagicCompRules.txt: 601.2h, 701.9a, 601.2b, 605.1a, 106.1, 601.2a.Claimed parse impact
None. No parser or type files are touched — this is a pure runtime cost-resolution fix; LED already parsed faithfully. The CI parse-diff artifact should report zero card-parse changes.
Scope Expansion
None. (The spell additional-cost arm was explicitly excluded — see Deliberately out of scope — to avoid masking the separate
Discard { Fixed(0) }parser misparse.)🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests