Skip to content

fix(engine): classify City-of-Brass-shaped self-tap damage into mana-source penalty (#5912)#6033

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
nghetienhiep:fix/issue-6020
Jul 16, 2026
Merged

fix(engine): classify City-of-Brass-shaped self-tap damage into mana-source penalty (#5912)#6033
matthewevans merged 3 commits into
phase-rs:mainfrom
nghetienhiep:fix/issue-6020

Conversation

@nghetienhiep

Copy link
Copy Markdown
Contributor

Summary

Closes #5912.

City of Brass (and reprints like Tarnished Citadel) print their self-damage
as a separate triggered ability — "Whenever this land becomes tapped, it
deals 1 damage to you." (TriggerMode::Taps) — rather than folding the
damage into the {T}: Add one mana of any color. ability's own resolution
chain the way painlands do (Adarkar Wastes: "{T}: Add {W} or {U}. This land
deals 1 damage to you." is a single ability).

mana_ability_penalty only walks the mana ability's own effect chain
(ability.effect/sub_ability/else_ability), so it never sees a sibling
TriggerDefinition on the same object. That misclassified City of Brass as
ManaSourcePenalty::None — byte-identical to a basic land — so auto-tap had
no reason to prefer a truly free source (Island) over it for a shared
generic-mana slot. Reported case: casting a {1}{G} spell with City of
Brass + Llanowar Elves in play tapped City of Brass (1 damage) instead of
Island, even though Island could pay the generic cost for free.

object_mana_ability_penalty (mana_sources.rs) merges the existing
ability-chain classification with a scan of the object's own
self-referential Taps triggers (object_self_tap_harm_amount), reusing the
same chain_harms_controller_amount walk a painland's embedded damage
already goes through. All three real consumers of the penalty classification
now route through it instead of the ability-only mana_ability_penalty, so
the fix can't drift between them:

  • scan_mana_abilities (auto-tap source sort — the reported bug)
  • activate_ability_is_meaningful_priority (priority-meaningful gate)
  • the UntapLandForMana undoability check in engine.rs (a City-of-Brass
    tap was previously misclassified as undoable even though its damage
    trigger had already resolved)

Files changed

  • crates/engine/src/game/mana_sources.rsobject_self_tap_harm_amount,
    object_mana_ability_penalty, wired into scan_mana_abilities; two new
    unit tests.
  • crates/engine/src/game/casting_costs.rs — new end-to-end auto-tap
    regression test.
  • crates/engine/src/ai_support/mod.rsactivate_ability_is_meaningful_priority
    now calls object_mana_ability_penalty.
  • crates/engine/src/game/engine.rsUntapLandForMana undo check now
    calls object_mana_ability_penalty.

CR references

  • CR 605.3b — mana ability resolution is atomic/inline; single
    classification authority for the penalty axis.
  • CR 603.2e — "becomes tapped" trigger semantics (fires per discrete tap
    event, not on persisting state).
  • CR 701.26 — tap/untap definitions.
  • CR 119.3 / CR 120.3 — life loss / damage-as-life-loss, the harm axis
    chain_harms_controller_amount already classifies for painlands.

Verification

  • cargo fmt --all — clean.
  • cargo clippy -p engine --all-targets --features engine/proptest -- -D warnings — 0 warnings.
  • cargo test -p engine --lib — 16754 passed, 0 failed, 7 ignored.
  • cargo test -p engine (integration suite) — 3208 passed, 0 failed, 2 ignored.
  • New tests specifically:
    • game::mana_sources::tests::city_of_brass_ability_alone_misclassifies_as_none
      (reach-guard: proves the ability-only classifier alone still returns
      None, i.e. the gap this PR closes).
    • game::mana_sources::tests::object_mana_ability_penalty_sees_city_of_brass_self_tap_damage_trigger
    • game::casting_costs::tests::auto_tap_prefers_free_land_over_city_of_brass_self_damage_trigger
      (drives the real auto_tap_mana_sources pipeline; fails against the
      pre-fix code because auto-tap would tap City of Brass and pay 1 life).
  • ./scripts/check-parser-combinators.sh origin/mainGate A PASS head=d5f81e071168fad4ade8489dfce50003d56ec60b base=3b52c67a9025cd20da8b68243667eb8cdad76060 (no parser files touched by
    this change).

Anchored on

  • crates/engine/src/game/mana_sources.rs:378 (chain_harms_controller_amount) —
    reused unmodified for the sibling trigger's execute chain.
  • crates/engine/src/game/casting_costs.rs:13487
    (auto_tap_prefers_non_life_mana_sources_when_equivalent) — existing
    end-to-end auto-tap regression pattern the new test mirrors.

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

Claimed parse impact

None — no parser files touched; this is a runtime/AI-support classification
fix only.

Closes #6020

…source penalty (phase-rs#5912)

City of Brass and similar lands print their self-damage as a separate
"Whenever this land becomes tapped, it deals 1 damage to you." trigger
(TriggerMode::Taps), not folded into the "{T}: Add one mana of any color."
ability's own resolution chain the way painlands do. mana_ability_penalty
only walked the mana ability's own chain, so it classified these lands
byte-identically to a basic land (ManaSourcePenalty::None) and auto-tap
could pick one over a truly free source (Island) for the same slot.

object_mana_ability_penalty merges the existing ability-chain
classification with a scan of the object's own self-referential Taps
triggers, and now backs auto-tap sort, the priority-meaningful gate, and
UntapLandForMana undoability so the three consumers cannot drift.

@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 #5912 by ensuring that mana abilities with sibling 'taps' triggers, such as City of Brass, are correctly classified for auto-tap and priority logic. It introduces a new authoritative function, object_mana_ability_penalty, which aggregates penalties from both the ability's own resolution chain and any self-referential sibling triggers. The engine, AI support, and auto-tap logic have been updated to utilize this function, and comprehensive regression tests have been added to verify the correct classification of these land types. The review comment suggests a refactoring of the new logic to improve idiomatic Rust usage and avoid Clippy lints.

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/mana_sources.rs Outdated
Comment on lines +475 to +498
let mut found = false;
let mut total = Some(0_u16);
for trigger in obj.trigger_definitions.iter_all() {
if trigger.mode != TriggerMode::Taps {
continue;
}
let self_referential = match &trigger.valid_card {
None => true,
Some(filter) => super::trigger_matchers::target_filter_matches_object(
state, object_id, filter, object_id,
),
};
if !self_referential {
continue;
}
let Some(execute) = trigger.execute.as_deref() else {
continue;
};
if let Some(amount) = chain_harms_controller_amount(execute) {
found = true;
total = fold_amount(total, amount);
}
}
found.then_some(total)

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

This function's implementation can be refactored to be more concise and idiomatic by using a functional approach with iterators. Please use is_none_or instead of map_or(true, ...) to comply with the repository's Rust 1.82+ style guidelines and avoid Clippy's unnecessary_map_or lint.

    let harm_amounts = obj
        .trigger_definitions
        .iter_all()
        .filter(|trigger| trigger.mode == TriggerMode::Taps)
        .filter(|trigger| {
            trigger.valid_card.as_ref().is_none_or(|filter| {
                super::trigger_matchers::target_filter_matches_object(
                    state,
                    object_id,
                    filter,
                    object_id,
                )
            })
        })
        .filter_map(|trigger| trigger.execute.as_deref())
        .filter_map(chain_harms_controller_amount);

    let mut harm_amounts_peekable = harm_amounts.peekable();
    if harm_amounts_peekable.peek().is_none() {
        None
    } else {
        Some(harm_amounts_peekable.fold(Some(0), fold_amount))
    }
References
  1. Do not replace Option::is_none_or with map_or(true, ...) in Rust codebases that support Rust 1.82.0 or newer, as this will trigger Clippy's unnecessary_map_or lint.

@matthewevans matthewevans self-assigned this Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@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.

Maintainer follow-up for 5625e3c3b6c0a07b82af0bc3fdfafb3d3e2087ba:

  • added the tap-cost guard so a no-tap mana ability cannot inherit a sibling TriggerMode::Taps penalty;
  • added the discriminating City-of-Brass-shaped no-tap regression; and
  • corrected the Tarnished Citadel explanation (its colored mana ability embeds its damage).

The change is held solely for the current required CI and the current-head parse-diff baseline (Baseline pending). The local Tilt instance watches a different checkout, so it cannot serve as verification for this worktree; no direct build was run. This is a status hold, not a request for contributor changes.

@matthewevans matthewevans added bug Bug fix quality For high-quality minimal to no-churn PRs labels Jul 16, 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.

Current-head maintainer review is clean.

  • The object-level helper now correctly charges City of Brass's sibling TriggerMode::Taps trigger, and the engine-level auto-tap regression distinguishes it from the prior ability-only classifier.
  • The no-tap guard prevents unrelated mana abilities from inheriting the object's tap-trigger penalty.
  • Current parse-diff reports no parser changes and required CI is green.

@matthewevans
matthewevans added this pull request to the merge queue Jul 16, 2026
@matthewevans matthewevans removed their assignment Jul 16, 2026
Address Gemini review nit on PR phase-rs#6033: replace the imperative found/total
loop with a filter/filter_map iterator chain using is_none_or, matching the
repo's stated preference for iterator methods over manual loops. Behavior
unchanged — same two City-of-Brass unit tests and the auto-tap end-to-end
regression test still pass.
@matthewevans matthewevans self-assigned this Jul 16, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 16, 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.

Current-head maintainer re-review is clean.

The author’s iterator rewrite preserves the prior sibling-trigger classifier: it filters only Taps triggers whose card filter matches the tapped source, skips missing execution chains, sums fixed harms, and retains an unknown amount as None. The City-of-Brass runtime regression and no-tap guard continue to discriminate the behavior. The current parser-impact evidence remains no card-parse changes.

@matthewevans
matthewevans enabled auto-merge July 16, 2026 17:42
@matthewevans matthewevans removed their assignment Jul 16, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 16, 2026
Merged via the queue into phase-rs:main with commit a0690f3 Jul 16, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix quality For high-quality minimal to no-churn PRs

Projects

None yet

2 participants