Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,14 @@ fn legacy_ability_condition(x: &AbilityCondition) -> bool {
| AbilityCondition::ControllerControlledMatchingAsCast { .. }
| AbilityCondition::SourceLacksKeyword { .. }
| AbilityCondition::WasStartingPlayer { .. }
// CR 903.3d: `false` here is about the D5 LEGACY-BATCH-PROMPT axis only —
// "does this leaf carry one of the 12 retained event-context refs" — not
// about reads. It carries none, exactly like the board-reading
// `ControllerControlsMatching` above and the `TriggerCondition` /
// `StaticCondition` mirrors below. The commander gate's actual read
// (a live battlefield census, CR 903.3d) is classified in
// `rw_ability_condition` via `commander_control_read`.
| AbilityCondition::ControlsCommander { .. }
| AbilityCondition::AdditionalCostPaidInstead
| AbilityCondition::AlternativeManaCostPaid
| AbilityCondition::EffectOutcome { .. }
Expand Down Expand Up @@ -3621,6 +3629,46 @@ fn board_membership_read(filter: &TargetFilter) -> RwProfile {
p
}

/// CR 903.3d + CR 603.3b: the single read profile for a commander-control gate,
/// shared by ALL THREE condition-vocabulary mirrors (`AbilityCondition`,
/// `TriggerCondition`, `StaticCondition`).
///
/// CR 903.3d: "If an effect refers to controlling a commander, it refers to a
/// permanent on the battlefield that is a commander." That is a LIVE BOARD
/// CENSUS, not a static card attribute: `game::commander::controls_own_commander`
/// / `controls_any_commander` scan `state.battlefield` for
/// `is_commander && controller == you [&& owner == you] && is_phased_in`. Only
/// the `is_commander` and `owner` conjuncts are frozen card attributes (CR 903.3);
/// battlefield MEMBERSHIP, the CONTROLLER field and CR 702.26b phased-in status
/// are all sibling-mutable, and `StateKind::SetMembership` is exactly "which
/// objects are where / whose" plus the kind a `PhaseOut` write records.
///
/// So this must NOT be `RwProfile::empty()`: a sibling copy whose effect moves a
/// commander onto or off the battlefield, steals it, or phases it out FEEDS this
/// gate's truth, and `group_is_order_independent` would otherwise auto-order the
/// pair against a gate that write flips (CR 603.3b). Modeled exactly like the
/// direct analogue `AbilityCondition::ControllerControlsMatching` —
/// `board_membership_read` over the population CR 903.3d names, expressed in the
/// engine's own filter vocabulary so the census/zone/controller spans are derived
/// by the one authority rather than hand-assembled.
///
/// `ControllerRef::You` for both ownership arms: CR 109.5 "you" is the evaluating
/// player, and `Own` merely adds the frozen owner conjunct on top of the same
/// controller-keyed census (a strictly narrower population, so the `You` span
/// stays sound).
fn commander_control_read() -> RwProfile {
board_membership_read(&TargetFilter::Typed(
TypedFilter::permanent()
.controller(ControllerRef::You)
.properties(vec![
FilterProp::IsCommander,
FilterProp::InZone {
zone: Zone::Battlefield,
},
]),
))
}

/// §L2 (CR 400.7 + CR 400.1): a zone-change per-turn journal read, keyed to its
/// DESTINATION zone when known. A `None` destination stays fail-closed (`Any`
/// zones, via `board_membership_read`). Never overrides a self-scoped read (whose
Expand Down Expand Up @@ -6199,6 +6247,15 @@ fn rw_ability_condition(x: &AbilityCondition) -> RwProfile {
}
p
}
// CR 903.3d: a LIVE battlefield census — see `commander_control_read`.
// The three condition-vocabulary mirrors of this ONE printed clause share
// that helper, so none of them can drift from the others.
// M3 binding mandate: `commander_control_read()` is a PRECISE profile, not
// `RwProfile::conservative()`, so this arm binds every payload field.
// `ownership` is deliberately not read: CR 903.3d makes both arms the same
// live battlefield census, and the Own/Any distinction narrows WHICH
// commanders qualify, not which state the read touches.
AbilityCondition::ControlsCommander { ownership: _ } => commander_control_read(),
AbilityCondition::AdditionalCostPaidInstead
| AbilityCondition::AlternativeManaCostPaid
| AbilityCondition::EffectOutcome { .. }
Expand Down Expand Up @@ -6339,10 +6396,14 @@ fn rw_trigger_condition(x: &TriggerCondition) -> RwProfile {
| TriggerCondition::TributeNotPaid
| TriggerCondition::CastDuringPhase { .. }
| TriggerCondition::CastTimingPermission { .. }
| TriggerCondition::ControlsCommander { .. }
| TriggerCondition::ChosenLabelIs { .. }
| TriggerCondition::ExceptFirstDrawInDrawStep
| TriggerCondition::PlacedByAbilitySource => RwProfile::empty(),
// CR 903.3d: a LIVE battlefield census — see `commander_control_read`.
// Shared with the `AbilityCondition` / `StaticCondition` mirrors of the
// same printed clause.
// M3 binding mandate: precise RHS, so bind every payload field.
TriggerCondition::ControlsCommander { ownership: _ } => commander_control_read(),
}
}

Expand Down Expand Up @@ -6439,12 +6500,16 @@ fn rw_static_condition(x: &StaticCondition) -> RwProfile {
| StaticCondition::WasCast { .. }
| StaticCondition::IsRingBearer
| StaticCondition::RingLevelAtLeast { .. }
| StaticCondition::ControlsCommander { .. }
| StaticCondition::SourceControllerEquals { .. }
| StaticCondition::EnchantedIsFaceDown
| StaticCondition::AdditionalCostPaid
| StaticCondition::CastingAsVariant { .. }
| StaticCondition::None => RwProfile::empty(),
// CR 903.3d: a LIVE battlefield census — see `commander_control_read`.
// Shared with the `AbilityCondition` / `TriggerCondition` mirrors of the
// same printed clause.
// M3 binding mandate: precise RHS, so bind every payload field.
StaticCondition::ControlsCommander { ownership: _ } => commander_control_read(),
}
}

Expand Down
31 changes: 29 additions & 2 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2705,6 +2705,21 @@ fn scan_ability_condition(x: &AbilityCondition, mode: ScanMode) -> Axes {
}
AbilityCondition::HasMaxSpeed => Axes::NONE,
AbilityCondition::IsMonarch => Axes::NONE,
// CR 903.3d: "controlling a commander" is a permanent ON THE BATTLEFIELD
// that is a commander — a live board census (`game::commander` scans
// `state.battlefield` for `is_commander && controller == you [&& owner ==
// you] && is_phased_in`), so a sibling copy that moves, steals or phases a
// commander can flip this gate (CR 603.3b ordering-relevance). Self-asserts
// its own `sibling: true` literal, as the ⛔ INVARIANT on
// `scan_target_filter`'s `Typed` arm requires of every board-aggregate
// caller. `event` stays false: the census reads no triggering-event
// characteristic. `ownership: _` is destructured explicitly (as the
// `TriggerCondition` mirror does) so a future field forces a re-audit here.
AbilityCondition::ControlsCommander { ownership: _ } => Axes {
event: false,
sibling: true,
projected: false,
},
// CR 309.7: controller-state predicate — touches no scan axis.
AbilityCondition::CompletedDungeon { .. } => Axes::NONE,
AbilityCondition::IsInitiative => Axes::NONE,
Expand Down Expand Up @@ -3393,7 +3408,13 @@ fn scan_trigger_condition(x: &TriggerCondition, mode: ScanMode) -> Axes {
sibling: true,
projected: false,
},
TriggerCondition::ControlsCommander { ownership: _ } => Axes::NONE,
// CR 903.3d: live battlefield census — same self-asserted board read as the
// `AbilityCondition` / `StaticCondition` mirrors of this printed clause.
TriggerCondition::ControlsCommander { ownership: _ } => Axes {
event: false,
sibling: true,
projected: false,
},
TriggerCondition::IsRenowned { subject: _ } => Axes::NONE,
TriggerCondition::HasCounters { .. } => Axes {
event: false,
Expand Down Expand Up @@ -3667,7 +3688,13 @@ fn scan_static_condition(x: &StaticCondition, mode: ScanMode) -> Axes {
StaticCondition::WasCast { zone: _ } => Axes::NONE,
StaticCondition::IsRingBearer => Axes::NONE,
StaticCondition::RingLevelAtLeast { level: _ } => Axes::NONE,
StaticCondition::ControlsCommander { ownership: _ } => Axes::NONE,
// CR 903.3d: live battlefield census — same self-asserted board read as the
// `AbilityCondition` / `TriggerCondition` mirrors of this printed clause.
StaticCondition::ControlsCommander { ownership: _ } => Axes {
event: false,
sibling: true,
projected: false,
},
StaticCondition::SourceIsTapped => Axes::NONE,
StaticCondition::IsTapped { scope, .. } => {
let mut acc = Axes::NONE;
Expand Down
Loading
Loading