From f532f8dbfe70a2fe425fc190ee7b44598384c98a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 3 Aug 2026 02:29:57 -0700 Subject: [PATCH 1/2] fix(engine): label flexible mana lands with the color they will produce (#6944) City of Brass, Reflecting Pool, Command Tower and friends rendered an unlabelled "Tap for mana" instead of showing the mana each activation would produce. `project_action_payload` handled `TapLandForMana` and `ActivateManaSource` in one arm and resolved both through `live_mana_source_option_for_selection`. But the two actions carry deliberately different selection forms: - `TapLandForMana` is minted from `ManaSourceOption::semantic_selection` -- one concrete row per producible color -- and is executed by `handle_tap_land_for_mana` via `live_land_mana_option_for_selection`. - `ActivateManaSource` is minted from `activatable_mana_source_selections`, whose `manual_selection_for_option` intentionally collapses a flexible source to `Colorless` + `DeferredColorChoice` so the ordinary mana-choice resolver asks for the color, and is executed by `activate_mana_source_selection` via `live_mana_source_option_for_selection`. That divergence is deliberate and is not the bug. The bug is that the label path resolved a planner-minted `TapLandForMana` through the *manual* authority, which can never match a flexible source -- so the lookup failed and the arm returned without pushing a surface. The old code was correct for `ActivateManaSource` and wrong for `TapLandForMana`. Split the arm so each action is labelled through the same resolver its own reducer executes, with the resolver passed to a shared `push_produced_mana_surfaces`. A future mana action variant now has to name an authority to compile, which is what the function's doc comment already claimed. The fix is variant-agnostic: `production_override_for_option` maps all eight `flexible_output` variants to `ProductionOverride::SingleColor`, so nothing is special-cased per card. Seven tests drive the real projection pipeline (`derive_viewer_interaction` over a viewer-filtered state), covering six of the eight flexible variants: City of Brass, Reflecting Pool, Exotic Orchard, Command Tower, Plaza of Heroes, Pit of Offerings, and a Resonating Lute grant. All seven were confirmed red at base by restoring the old resolver and re-running -- 7 failures, no collateral -- reproducing the report verbatim, including mixed rows where a non-flexible sibling ability keeps its label while the flexible one goes blank. Not covered: `AnyCombination` has no bare-{T} land printing (every printing gates it behind a Composite/PaySpeed cost, so it needs a funded pool) and `AnyCombinationOfObjectColors` is unreachable for its only current printing, as already documented at casting_costs.rs. Both share the fixed code path. --- crates/engine/src/game/interaction.rs | 107 +++++-- .../tests/integration/interaction_contract.rs | 300 ++++++++++++++++++ 2 files changed, 376 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 1c563d6d97..723d8467c0 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -55,8 +55,8 @@ use crate::types::interaction::{ ViewerInteraction, MAX_INTERACTION_LIST_LEN, }; use crate::types::mana::{ - AbilityActivationScope, ManaColor, ManaCost, ManaRestriction, ManaType, SpecialAction, - SpellCostCriterion, ZoneSpendPolarity, + AbilityActivationScope, ManaColor, ManaCost, ManaRestriction, ManaSourceSelection, ManaType, + SpecialAction, SpellCostCriterion, ZoneSpendPolarity, }; use crate::types::match_config::DeckCardCount; use crate::types::player::PlayerId; @@ -4189,6 +4189,49 @@ fn push_object_list( } } +/// Label one mana action with the mana its own reducer would actually produce. +/// `resolve` is the caller-supplied authority — the exact function the reducer +/// for that action variant uses to revalidate the frozen selection — so a label +/// can never be derived through a sibling surface's selection form. A stale or +/// no-longer-legal selection resolves to no produced-mana surface, matching the +/// reducer's own refusal to activate it. +fn push_produced_mana_surfaces( + surfaces: &mut Vec, + state: &GameState, + selection: &ManaSourceSelection, + resolve: fn( + &GameState, + PlayerId, + &ManaSourceSelection, + ) -> Result, +) { + let Some(player) = state + .objects + .get(&selection.source.object_id) + .map(|object| object.controller) + else { + return; + }; + let Ok(option) = resolve(state, player, selection) else { + return; + }; + for (index, unit) in mana_sources::live_mana_output_for_option(state, player, &option) + .into_iter() + .enumerate() + { + surfaces.push(InteractionPresentationSurface::Mana { + role: InteractionRoleCode::ProducedMana, + index: Some(index as u32), + symbols: vec![mana_type_code(unit.mana_type).to_string()], + restrictions: unit + .restrictions + .iter() + .map(interaction_mana_restriction) + .collect(), + }); + } +} + /// Exhaustive, viewer-filtered projection of the fields that distinguish one /// exact action candidate from its siblings. This is intentionally action /// aware: adding a `GameAction` variant is a compile-time obligation here. @@ -4212,35 +4255,37 @@ fn project_action_payload( GameAction::ChooseEntryAttackTarget { target } => { push_attack_target_surface(surfaces, state, target, InteractionRoleCode::AttackTarget) } - GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { - let Some(player) = state - .objects - .get(&selection.source.object_id) - .map(|object| object.controller) - else { - return; - }; - let Ok(option) = - mana_sources::live_mana_source_option_for_selection(state, player, selection) - else { - return; - }; - for (index, unit) in mana_sources::live_mana_output_for_option(state, player, &option) - .into_iter() - .enumerate() - { - surfaces.push(InteractionPresentationSurface::Mana { - role: InteractionRoleCode::ProducedMana, - index: Some(index as u32), - symbols: vec![mana_type_code(unit.mana_type).to_string()], - restrictions: unit - .restrictions - .iter() - .map(interaction_mana_restriction) - .collect(), - }); - } - } + // The two public mana-action surfaces carry deliberately different + // selection forms, so each must be labelled through the same authority + // that will execute it. + // + // `TapLandForMana` is minted by `activatable_mana_actions_for_player` + // from `ManaSourceOption::semantic_selection` — one *concrete* row per + // producible color — and is executed by `handle_tap_land_for_mana` via + // `live_land_mana_option_for_selection`. + // + // `ActivateManaSource` is minted from + // `activatable_mana_source_selections`, whose `manual_selection_for_option` + // intentionally collapses a flexible source to `Colorless` + + // `DeferredColorChoice` so the ordinary mana-choice resolver asks for the + // color, and is executed by `activate_mana_source_selection` via + // `live_mana_source_option_for_selection`. + // + // Resolving one through the other's authority can never match a flexible + // source, which silently produced an unlabelled action (issue #6944: + // City of Brass). Keep each arm paired with its own reducer's resolver. + GameAction::TapLandForMana { selection } => push_produced_mana_surfaces( + surfaces, + state, + selection, + mana_sources::live_land_mana_option_for_selection, + ), + GameAction::ActivateManaSource { selection } => push_produced_mana_surfaces( + surfaces, + state, + selection, + mana_sources::live_mana_source_option_for_selection, + ), GameAction::PlayLand { .. } | GameAction::Foretell { .. } | GameAction::UntapLandForMana { .. } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 54211f8425..bc69db5cec 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -3105,3 +3105,303 @@ fn published_interaction_choices_never_offer_a_debug_action_in_a_sandbox_game() actor_candidates path ran" ); } + +// --------------------------------------------------------------------------- +// Issue #6944: a flexible-mana land rendered an unlabelled "Tap for mana". +// +// `TapLandForMana` candidates are minted from `ManaSourceOption::semantic_selection` +// (one *concrete* row per producible color) and executed via +// `live_land_mana_option_for_selection`. The label projection resolved them +// through the *manual* authority (`live_mana_source_option_for_selection`) +// instead, whose `manual_selection_for_option` deliberately collapses a flexible +// source to `Colorless` + `DeferredColorChoice`. The concrete row therefore never +// matched, the resolver returned `Err`, and the projection silently emitted no +// `ProducedMana` surface at all. +// +// Every test below asserts a *non-empty* produced-mana label for a flexible +// source, which is exactly the surface that was missing before the fix. +// --------------------------------------------------------------------------- + +/// Produced-mana symbols projected for each `TapLandForMana` candidate whose +/// source is `source` — one inner `Vec` per candidate, one entry per produced +/// mana unit. An unlabelled candidate surfaces as an empty inner `Vec`. +fn projected_land_mana_labels( + state: &mut GameState, + source: ObjectId, + binding: &str, +) -> Vec> { + bind(state, binding); + let view = priority_view(state); + let InteractionOpportunityResponse::ExactChoices { choices } = &view.opportunities[0].response + else { + panic!("priority is projected as exact choices"); + }; + choices + .iter() + .filter(|choice| { + choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Action { + code: InteractionActionCode::TapLandForMana, + .. + } + ) + }) && choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Object { + role: InteractionRoleCode::Source, + reference, + .. + } if reference == &source.0.to_string() + ) + }) + }) + .map(|choice| { + choice + .surfaces + .iter() + .filter_map(|surface| match surface { + InteractionPresentationSurface::Mana { + role: InteractionRoleCode::ProducedMana, + symbols, + .. + } => symbols.first().cloned(), + _ => None, + }) + .collect() + }) + .collect() +} + +/// Flatten per-candidate labels into one sorted symbol list, asserting that no +/// candidate was left unlabelled. The unlabelled case is the #6944 regression. +fn sorted_labelled_symbols(labels: &[Vec], context: &str) -> Vec { + assert!( + !labels.is_empty(), + "{context}: expected at least one TapLandForMana candidate" + ); + assert!( + labels.iter().all(|units| !units.is_empty()), + "{context}: every mana candidate must carry a produced-mana label, got {labels:?}" + ); + let mut symbols: Vec = labels.iter().flatten().cloned().collect(); + symbols.sort(); + symbols +} + +#[test] +fn tap_land_for_mana_labels_each_color_of_an_any_one_color_land() { + // ManaProduction::AnyOneColor — the card from issue #6944. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let city = scenario + .add_land_from_oracle( + P0, + "City of Brass", + "Whenever this land becomes tapped, it deals 1 damage to you.\n{T}: Add one mana of any color.", + ) + .id(); + let mut runner = scenario.build(); + + let labels = projected_land_mana_labels(runner.state_mut(), city, "city-of-brass-mana-label"); + assert_eq!( + sorted_labelled_symbols(&labels, "City of Brass"), + ["B", "G", "R", "U", "W"], + "each concrete color row must project its own color, not an unlabelled tap" + ); + assert!( + labels.iter().all(|units| units.len() == 1), + "'Add one mana of any color' produces exactly one unit per row: {labels:?}" + ); +} + +#[test] +fn tap_land_for_mana_labels_a_granted_flexible_mana_ability() { + // ManaProduction::AnyOneColor { count: 2 } reached through a `GrantAbility` + // static — the second card named in issue #6944. The label must carry both + // produced units and the granted spend restriction. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_enchantment_from_oracle( + P0, + "Resonating Lute", + "Lands you control have \"{T}: Add two mana of any one color. Spend this mana only to cast instant and sorcery spells.\"\n{T}: Draw a card. Activate only if you have seven or more cards in your hand.", + ); + // An explicitly-printed mana ability, not `add_basic_land`: a basic land's + // production is subtype-inferred by `land_mana_options`, and that fallback is + // deliberately suppressed once any explicit `Effect::Mana` ability exists — + // which the grant itself supplies. Printing the ability keeps this test about + // the label projection rather than the basic-land fallback. + let forest = scenario + .add_land_from_oracle(P0, "Forest", "{T}: Add {G}.") + .id(); + let mut runner = scenario.build(); + // `GameScenario::build` does not run a layer pass, so the `GrantAbility` + // static has not yet been applied to the land's ability list. + engine::game::layers::evaluate_layers(runner.state_mut()); + + let labels = projected_land_mana_labels(runner.state_mut(), forest, "resonating-lute-grant"); + let symbols = sorted_labelled_symbols(&labels, "Resonating Lute granted ability"); + let granted: Vec<&Vec> = labels.iter().filter(|units| units.len() == 2).collect(); + assert_eq!( + granted.len(), + 5, + "the granted 'two mana of any one color' ability exposes one two-unit row \ + per color: {labels:?}" + ); + assert!( + granted + .iter() + .all(|units| units[0] == units[1] && symbols.contains(&units[0])), + "'any one color' produces two units of the SAME chosen color: {granted:?}" + ); + assert!( + labels.iter().any(|units| units == &vec!["G".to_string()]), + "the Forest's own printed mana ability is still labelled: {labels:?}" + ); +} + +#[test] +fn tap_land_for_mana_labels_an_any_type_produceable_by_land() { + // ManaProduction::AnyTypeProduceableBy. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let pool = scenario + .add_land_from_oracle( + P0, + "Reflecting Pool", + "{T}: Add one mana of any type that a land you control could produce.", + ) + .id(); + scenario.add_basic_land(P0, ManaColor::Green); + let mut runner = scenario.build(); + + let labels = projected_land_mana_labels(runner.state_mut(), pool, "reflecting-pool-mana-label"); + assert_eq!( + sorted_labelled_symbols(&labels, "Reflecting Pool"), + ["G"], + "the surveyed Forest's type is the only produceable type" + ); +} + +#[test] +fn tap_land_for_mana_labels_an_opponent_land_colors_land() { + // ManaProduction::OpponentLandColors. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let orchard = scenario + .add_land_from_oracle( + P0, + "Exotic Orchard", + "{T}: Add one mana of any color that a land an opponent controls could produce.", + ) + .id(); + scenario.add_basic_land(P1, ManaColor::Blue); + let mut runner = scenario.build(); + + let labels = projected_land_mana_labels(runner.state_mut(), orchard, "exotic-orchard-label"); + assert_eq!( + sorted_labelled_symbols(&labels, "Exotic Orchard"), + ["U"], + "the opponent's Island is the only surveyed color" + ); +} + +#[test] +fn tap_land_for_mana_labels_a_commander_color_identity_land() { + // ManaProduction::AnyInCommandersColorIdentity. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let tower = scenario + .add_land_from_oracle( + P0, + "Command Tower", + "{T}: Add one mana of any color in your commander's color identity.", + ) + .id(); + let commander = scenario + .add_creature(P0, "Mono-Red Commander", 2, 2) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_commander(commander); + let mut runner = scenario.build(); + + let labels = projected_land_mana_labels(runner.state_mut(), tower, "command-tower-label"); + assert_eq!( + sorted_labelled_symbols(&labels, "Command Tower"), + ["R"], + "the label follows the commander's color identity" + ); +} + +#[test] +fn tap_land_for_mana_labels_an_any_color_among_permanents_land() { + // ManaProduction::AnyOneColorAmongPermanents. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let plaza = scenario + .add_land_from_oracle( + P0, + "Plaza of Heroes", + "{T}: Add {C}.\n{T}: Add one mana of any color. Spend this mana only to cast a legendary spell.\n{T}: Add one mana of any color among legendary permanents you control.\n{3}, {T}, Exile this land: Target legendary creature gains hexproof and indestructible until end of turn.", + ) + .id(); + scenario + .add_creature(P0, "Legendary Red Bear", 2, 2) + .as_legendary() + .with_mana_cost(ManaCost::Cost { + generic: 1, + shards: vec![ManaCostShard::Red], + }); + let mut runner = scenario.build(); + + let labels = projected_land_mana_labels(runner.state_mut(), plaza, "plaza-of-heroes-label"); + let symbols = sorted_labelled_symbols(&labels, "Plaza of Heroes"); + assert!( + symbols.contains(&"R".to_string()), + "the among-legendary-permanents ability projects the legend's color: {labels:?}" + ); + assert!( + symbols.contains(&"C".to_string()), + "the sibling colorless ability stays labelled: {labels:?}" + ); +} + +#[test] +fn tap_land_for_mana_labels_a_choice_among_exiled_colors_land() { + // ManaProduction::ChoiceAmongExiledColors. + let Some(db) = load_db() else { + return; + }; + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let pit = scenario + .add_land_from_oracle( + P0, + "Pit of Offerings", + "{T}: Add {C}.\n{T}: Add one mana of any of the exiled cards' colors.", + ) + .id(); + let exiled = scenario.add_real_card(P0, "Lightning Bolt", Zone::Exile, db); + let mut runner = scenario.build(); + runner + .state_mut() + .exile_links + .push(engine::types::game_state::ExileLink { + exiled_id: exiled, + source_id: pit, + kind: engine::types::game_state::ExileLinkKind::TrackedBySource, + }); + + let labels = projected_land_mana_labels(runner.state_mut(), pit, "pit-of-offerings-label"); + assert_eq!( + sorted_labelled_symbols(&labels, "Pit of Offerings"), + ["C", "R"], + "the exiled red card's color is labelled alongside the colorless sibling" + ); +} From 8ee25c75b606dda93499df2a80bcc04916bb2f6f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 3 Aug 2026 03:01:33 -0700 Subject: [PATCH 2/2] test(engine): cover the ActivateManaSource mana-label path The seven tests added with the fix all drive `TapLandForMana`. The `ActivateManaSource` arm keeps its original resolver and is behaviourally unchanged, but its surface emission now runs through the extracted `push_produced_mana_surfaces`, and nothing covered that. Reaching it is not obvious: the reducer accepts `ActivateManaSource` under `WaitingFor::Priority` (`engine.rs`), but `direct_choice_projection` only constructs those actions in its `WaitingFor::ManaSourceSelection` arm, so a label test has to drive the game into a mana-source-selection window rather than activate at priority. A sacrificial mana source gets there. Covers a fixed and a flexible source through the same window, so the shared helper is pinned for both arms rather than only the one the fix changed. Raised by CodeRabbit on #6949's sibling PR. --- .../tests/integration/interaction_contract.rs | 154 +++++++++++++++++- 1 file changed, 152 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index bc69db5cec..25e28a02fa 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -14,8 +14,8 @@ use engine::game::visibility::filter_state_for_viewer; use engine::game::DeckEntry; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, CardSelectionMode, Chooser, ChosenAttribute, - CounterCostSelection, Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, - TypedFilter, ZoneOwner, + CounterCostSelection, Effect, ManaContribution, ManaProduction, QuantityExpr, ResolvedAbility, + SacrificeCost, TargetFilter, TargetRef, TypedFilter, ZoneOwner, }; use engine::types::actions::{GameAction, MulliganChoice}; use engine::types::card::CardFace; @@ -3405,3 +3405,153 @@ fn tap_land_for_mana_labels_a_choice_among_exiled_colors_land() { "the exiled red card's color is labelled alongside the colorless sibling" ); } + +// --------------------------------------------------------------------------- +// Sibling coverage: `ActivateManaSource`. +// +// The two mana surfaces now share `push_produced_mana_surfaces`, each passing +// its own reducer's resolver. The tests above pin the `TapLandForMana` arm; this +// one pins the `ActivateManaSource` arm so the shared helper cannot be changed +// to satisfy one caller while silently dropping the other's labels. +// +// `ActivateManaSource` is only ever projected from the +// `WaitingFor::ManaSourceSelection` arm of `direct_choice_projection` — no +// priority arm mints it — so the fixture must drive the real cast pipeline into +// that window. `CastPaymentMode::AutoExceptSacrificialMana` does exactly that: +// the automatic planner refuses to spend an irreversible sacrifice row without +// explicit consent and hands the choice back as `ManaSourceSelection`. +// --------------------------------------------------------------------------- + +fn sacrificial_mana_source(produced: ManaProduction) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::SelfRef, + 1, + ))) +} + +/// Produced-mana symbols projected for the `ActivateManaSource` candidates whose +/// source is `source` — one inner `Vec` per candidate, one entry per produced +/// mana unit. An unlabelled candidate surfaces as an empty inner `Vec`. +fn projected_mana_source_labels( + state: &mut GameState, + source: ObjectId, + binding: &str, +) -> Vec> { + bind(state, binding); + let view = viewer_interaction(state, P0); + let InteractionOpportunityResponse::ExactChoices { choices } = &view.opportunities[0].response + else { + panic!("the mana-source prompt is projected as exact choices"); + }; + choices + .iter() + .filter(|choice| { + choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Action { + code: InteractionActionCode::ActivateManaSource, + .. + } + ) + }) && choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Object { + role: InteractionRoleCode::Source, + reference, + .. + } if reference == &source.0.to_string() + ) + }) + }) + .map(|choice| { + choice + .surfaces + .iter() + .filter_map(|surface| match surface { + InteractionPresentationSurface::Mana { + role: InteractionRoleCode::ProducedMana, + symbols, + .. + } => symbols.first().cloned(), + _ => None, + }) + .collect() + }) + .collect() +} + +#[test] +fn activate_mana_source_labels_fixed_and_flexible_sacrificial_sources() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand(P0, "Mana Source Label Witness", true) + .with_mana_cost(ManaCost::generic(1)) + .id(); + // Both rows must be sacrifice-only: a non-sacrificial row on either source + // would let the automatic planner pay without ever opening the prompt. + let fixed = scenario + .add_creature(P0, "Fixed Output Witness", 1, 1) + .with_ability_definition(sacrificial_mana_source(ManaProduction::Fixed { + colors: vec![ManaColor::Black], + contribution: ManaContribution::Base, + })) + .id(); + let flexible = scenario + .add_creature(P0, "Flexible Output Witness", 1, 1) + .with_ability_definition(sacrificial_mana_source(ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 2 }, + color_options: vec![ManaColor::Red, ManaColor::Green], + contribution: ManaContribution::Base, + })) + .id(); + let mut runner = scenario.build(); + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::AutoExceptSacrificialMana, + }) + .expect("the production cast path should stop for sacrificial-mana consent"); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::ManaSourceSelection { .. } + ), + "ActivateManaSource is projected only from this window, got {:?}", + runner.state().waiting_for + ); + + let fixed_labels = projected_mana_source_labels(runner.state_mut(), fixed, "fixed-mana-source"); + assert_eq!( + fixed_labels, + vec![vec!["B".to_string()]], + "a fixed sacrificial source projects its one concrete produced unit" + ); + + let flexible_labels = + projected_mana_source_labels(runner.state_mut(), flexible, "flexible-mana-source"); + assert_eq!( + flexible_labels, + vec![vec!["R".to_string(), "R".to_string()]], + "a flexible source is offered as ONE deferred-color candidate whose label \ + still carries both produced units; `manual_selection_for_option` collapses \ + it to Colorless + DeferredColorChoice, so resolving it through the land \ + authority (the #6944 bug) would drop this label entirely" + ); +}