Implement Enlist keyword (CR 702.154) - #2535
Conversation
Keyword::Enlist was parsed but inert. Synthesize it as an optional Attacks
trigger (the Provoke shape): the optional body taps an untapped creature you
control; a reflexive sub-ability pumps the attacker (SelfRef) by that
creature's power until end of turn (+X/+0).
X is read anaphorically: the just-tapped creature is the resolution's 'that
creature' referent (CR 608.2c), reached via Effect::Pump's
PtValue::Quantity(QuantityRef::Power { scope: Anaphoric }). To make that work,
extend parent_referent_context_from_events to capture a single tapped creature
(a PermanentTapped event, snapshot live) as the anaphoric referent — tried
last, after the existing sacrifice/move/reveal referents, so it only fills the
'that creature' slot when nothing else did. Per CR 608.2c a later instruction
may refer to a creature an earlier instruction tapped, so this is a correct
generalization, not Enlist-specific plumbing.
Reuses TriggerMode::Attacks + Effect::Tap + Effect::Pump with
PtValue::Quantity + QuantityRef::Power. No new Effect or subsystem.
Eligibility note: the tap filter is 'another untapped creature you control'.
CR 702.154a's 'didn't choose to attack with' is largely covered (attackers
tap unless vigilant); the 'haste or controlled since the turn began'
(summoning-sickness) refinement has no FilterProp yet and is left as a
follow-up tightening rather than blocking the core mechanic.
Tests: synthesis-shape (optional Attacks trigger; Tap over untapped
you-control creature; reflexive Pump of SelfRef by Power{Anaphoric}; +0
toughness), idempotency, no-op, triggers_for/matcher roundtrip; plus referent
unit tests (a single tapped creature is captured; multiple are not).
Closes phase-rs#2534
There was a problem hiding this comment.
Code Review
This pull request implements the Enlist keyword mechanic (CR 702.154a) by synthesizing an optional attacks trigger that taps an untapped creature and pumps the attacker, and captures the tapped creature as an anaphoric referent (CR 608.2c). Feedback highlights two issues: first, the target filter incorrectly allows tapping the enlisting creature itself or other attacking creatures with vigilance; second, duplicate events for the same permanent can cause the referent capture to fail, which can be resolved by deduplicating the tapped object IDs.
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.
| let tap_target = TargetFilter::Typed( | ||
| TypedFilter::creature() | ||
| .controller(ControllerRef::You) | ||
| .properties(vec![FilterProp::Untapped]), | ||
| ); |
There was a problem hiding this comment.
[HIGH] Target filter allows tapping the enlisting creature itself or other attacking creatures with vigilance.
Evidence: crates/engine/src/database/synthesis.rs:4343-4347.
Why it matters: CR 702.154a specifies that you may tap up to one untapped creature you control that you didn't choose to attack with. The current filter only checks for FilterProp::Untapped, which incorrectly allows tapping the enlisting creature itself (if it has vigilance) or other attacking creatures with vigilance.
Suggested fix: Exclude the enlisting creature and other attacking creatures from the target filter (e.g., by using FilterProp::Another and/or checking for non-attacking status if those properties exist).
References
- Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. Convenience shortcuts that get rules wrong are not simpler; they are wrong. (link)
| fn tapped_object_context_from_events( | ||
| state: &GameState, | ||
| events: &[GameEvent], | ||
| ) -> Option<CostPaidObjectSnapshot> { | ||
| let mut tapped = events.iter().filter_map(|event| match event { | ||
| GameEvent::PermanentTapped { object_id, .. } => { | ||
| state | ||
| .objects | ||
| .get(object_id) | ||
| .map(|obj| CostPaidObjectSnapshot { | ||
| object_id: *object_id, | ||
| lki: obj.snapshot_for_mana_spent(), | ||
| }) | ||
| } | ||
| _ => None, | ||
| }); | ||
| let first = tapped.next()?; | ||
| tapped.next().is_none().then_some(first) | ||
| } |
There was a problem hiding this comment.
[MED] Multiple events for the same permanent can cause the referent capture to fail.
Evidence: crates/engine/src/game/effects/mod.rs:801-819.
Why it matters: If a single permanent is tapped but generates multiple PermanentTapped events (e.g., due to replacement effects or duplicate event dispatch), tapped.next().is_none() will return false and fail to capture the referent.
Suggested fix: Deduplicate the tapped object IDs or verify that all tapped events refer to the same object_id before returning the snapshot.
| fn tapped_object_context_from_events( | |
| state: &GameState, | |
| events: &[GameEvent], | |
| ) -> Option<CostPaidObjectSnapshot> { | |
| let mut tapped = events.iter().filter_map(|event| match event { | |
| GameEvent::PermanentTapped { object_id, .. } => { | |
| state | |
| .objects | |
| .get(object_id) | |
| .map(|obj| CostPaidObjectSnapshot { | |
| object_id: *object_id, | |
| lki: obj.snapshot_for_mana_spent(), | |
| }) | |
| } | |
| _ => None, | |
| }); | |
| let first = tapped.next()?; | |
| tapped.next().is_none().then_some(first) | |
| } | |
| fn tapped_object_context_from_events( | |
| state: &GameState, | |
| events: &[GameEvent], | |
| ) -> Option<CostPaidObjectSnapshot> { | |
| let mut tapped_ids = events.iter().filter_map(|event| match event { | |
| GameEvent::PermanentTapped { object_id, .. } => Some(*object_id), | |
| _ => None, | |
| }); | |
| let first_id = tapped_ids.next()?; | |
| for id in tapped_ids { | |
| if id != first_id { | |
| return None; | |
| } | |
| } | |
| state.objects.get(&first_id).map(|obj| CostPaidObjectSnapshot { | |
| object_id: first_id, | |
| lki: obj.snapshot_for_mana_spent(), | |
| }) | |
| } |
References
- Edge cases: Simultaneous events, multi-target/modal interactions, and duplicate event handling should be robustly covered. (link)
Architecture ReviewImplementation review (engine, keyword). Findings only. Nice reuse — Enlist is synthesized as the optional [MED] The enlist tap filter is over-permissive vs CR 702.154a — it allows two illegal targets. CR 702.154a: "you may tap up to one untapped creature you control that you didn't choose to attack with and that either has haste or has been under your control continuously since this turn began." The PR's filter is just "another untapped creature you control," missing both qualifiers:
Both are real CR deviations (the #1 hard rule is rules-correctness). The first is honestly flagged; the second is implied by the same shortcut. Suggested fix: add the not-summoning-sick [LOW] The anaphoric tapped-referent is a shared-resolution change — verify no existing single-tap effect regresses. Extending 🤖 Generated with Claude Code |
|
Pushed a maintainer follow-up commit (
Verification: |
matthewevans
left a comment
There was a problem hiding this comment.
Maintainer follow-up addressed the Enlist eligibility and tapped-referent review findings. Architecture check: correct engine synthesis/filter seam, reusable eligibility predicate, no frontend logic, CR annotations verified.
Summary
Implements the Enlist keyword (CR 702.154), closing #2534.
Keyword::Enlistwas parsed but inert. It is synthesized as an optionalAttackstrigger (the Provoke shape): the optional body taps an untapped creature you control; a reflexive sub-ability pumps the attacker (SelfRef) by that creature's power until end of turn (+X/+0).CR
CR 702.154a (verified against
docs/MagicCompRules.txt:5114): "As this creature attacks, you may tap up to one untapped creature you control … When you do, this creature gets +X/+0 until end of turn, where X is the tapped creature's power."How X is read (the one cross-cutting piece)
The pump must affect the attacker while reading a different creature's power. X is read anaphorically: the just-tapped creature is the resolution's "that creature" referent (CR 608.2c), reached via
Effect::Pump'sPtValue::Quantity(QuantityRef::Power { scope: Anaphoric }).To make that resolve,
parent_referent_context_from_eventsis extended to capture a single tapped creature (from aPermanentTappedevent, snapshot live) as the anaphoric referent — tried last, after the existing sacrifice/move/reveal referents, so it only fills the "that creature" slot when nothing else did. Per CR 608.2c a later instruction may refer to a creature an earlier instruction tapped, so this is a correct generalization, not Enlist-specific plumbing.Reuses
TriggerMode::Attacks+Effect::Tap+Effect::PumpwithPtValue::Quantity+QuantityRef::Power. No newEffector subsystem.Changes
database/synthesis.rs:build_enlist_trigger,is_enlist_trigger, thetriggers_for/matcher arms,synthesize_enlist, and thesynthesize_allwiring (beside Provoke/Melee).game/effects/mod.rs:tapped_object_context_from_events+ its hook inparent_referent_context_from_events.Eligibility note (honest scope)
The tap filter is "another untapped creature you control." CR 702.154a's "didn't choose to attack with" is largely covered (attackers tap unless they have vigilance), but the "has haste or has been under your control since the turn began" (summoning-sickness) refinement has no
FilterPropyet, so it is left as a follow-up tightening rather than blocking the core mechanic. Flagging for reviewer visibility.Tests
Attackstrigger; body taps an untapped you-control creature; reflexivePumpofSelfRefbyPower{Anaphoric}with +0 toughness; idempotency; no-op without keyword;triggers_for/matcher roundtrip.Verification
cargo fmt --all— clean.cargo clippy -p engine --all-targets --features proptest -- -D warnings— clean.scripts/check-parser-combinators.sh— passes.Closes #2534