fix(engine): parse Land Equilibrium's chained control-gated sacrifice replacement#5602
Conversation
… replacement
Land Equilibrium ("If an opponent who controls at least as many lands as
you do would put a land onto the battlefield, that player instead puts
that land onto the battlefield then sacrifices a land of their choice")
previously parsed as a swallowed clause with zero replacement coverage.
Adds a nom combinator for the "an opponent who controls at least as many
<X> as you do" subject-relative clause (CR 614.1a), reusing the existing
comparator-agnostic player_count_comparison_filters helper, and wires it
into a new ReplacementDefinition builder that gates on the entering
opponent's own land count (not an aggregate/existential check across all
opponents) via a new ScopedPlayer-aware QuantityContext threaded through
OnlyIfQuantity/UnlessQuantity condition evaluation.
Also fixes a latent continuation-source bug in
stash_post_replacement_continuation (game/replacement.rs) that only
manifests on the TokenEntry drain path — the existing land-play and
general zone-change drains already rebind correctly, so this is a no-op
for all 21 existing Devour-family cards and closes the one remaining gap
for external (non-self-referential) replacements creating land tokens.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Approved — right seam, verified rules evidence, and a surgical one-card parse-diff. Held last sweep only for missing evidence; both gaps have now closed favorably.
✅ Clean
Right seam — the combinator rejects the shape that would have been subtly wrong. The new nom combinator reuses player_count_comparison_filters and explicitly declines the existential-aggregate PlayerFilter::ControlsCount shape. That matters: an aggregate filter would answer "does some player control more lands?" when the rule needs "does this entering opponent control more lands?" — it would pass casual tests and bind the wrong player in a multiplayer game. Rejecting it is the harder and correct choice.
Shared-seam change is correctly scoped. rid.source → new_event.affected_object_id().unwrap_or(rid.source) touches the Applied arm only. Leaving the Prevented arm alone is right — a prevented event has no entering object to bind to — and the unwrap_or fallback plus the Devour-family regression test guard the blast radius.
Evidence, verified rather than taken on trust:
- Oracle text matches Scryfall exactly (checked against the card, not the PR description).
- All 6 CR citations grep-verify against
docs/MagicCompRules.txt: 614.1a, 614.1c, 614.13, 608.2c, 109.5, 701.21a. - Parse-diff: exactly 1 card, 2 signatures. Land Equilibrium loses a bogus
ChangeZone (target=parent target, to=battlefield)and gainsreplacement/Moved. Zero collateral anywhere else in the pool — precisely the claimed scope, which is the signature you want from a replacement-effect fix. - Tests are registered in
tests/integration/main.rs(not inert) and discriminating:three_player_gate_binds_to_specific_entering_playerwould fail against an aggregate implementation.
CI is genuinely green — both Rust shards ran real work (9m14s / 9m15s), lint 8m18s, card-data 10m21s. Not a hollow pass.
Recommendation: approve and enqueue. Nothing blocking.
Summary
Land Equilibrium ({2}{U}{U}, Enchantment, Legends): "If an opponent who controls at least as many lands as you do would put a land onto the battlefield, that player instead puts that land onto the battlefield then sacrifices a land of their choice."
Previously parsed with zero coverage — the chained "then sacrifices" clause was swallowed (
SwallowedClause/Replacement_Insteadparse warning,replacements: []).<X>as you do" subject-relative clause (CR 614.1a), reusing the existing comparator-agnosticplayer_count_comparison_filtershelper rather than the existential-aggregate shape used by "an opponent controls more lands than you."ReplacementDefinitionthat gates on the entering opponent's own land count — not an aggregate check across all opponents — via a newScopedPlayer-awareQuantityContextthreaded throughOnlyIfQuantity/UnlessQuantitycondition evaluation (game/replacement.rs). Verified this is additive: none of the 27 existing cards using these condition variants referenceScopedPlayertoday.None, falls through to the next dispatch branch) rather than misparsing.stash_post_replacement_continuation: the stashed source usedrid.source(the replacement's bearer) instead of the entering object. This is a no-op for every existing card reaching this code path (all 21 Devour-family cards havevalid_card: SelfRef, so bearer == entering object by construction — verified via a whole-database query), but is required for Land Equilibrium'sTokenEntrydrain path (an opponent creating a land token under Land Equilibrium), since it's the first external (non-self-referential) replacement to reach this code.Test plan
Movedreplacement withOnlyIfQuantity{GE}and aSacrificeexecute (noSwallowedClause), asserting exact target-filter equality.land_equilibrium_forced_sacrifice.rs, 5 scenarios), driving the realGameRunner/PlayLandpipeline:cargo clippy -p engine --tests -- -D warningscleancargo fmt --all./scripts/check-parser-combinators.sh) clean🤖 Generated with Claude Code
https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K