Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
107 changes: 76 additions & 31 deletions crates/engine/src/game/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InteractionPresentationSurface>,
state: &GameState,
selection: &ManaSourceSelection,
resolve: fn(
&GameState,
PlayerId,
&ManaSourceSelection,
) -> Result<mana_sources::ManaSourceOption, EngineError>,
) {
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.
Expand All @@ -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 { .. }
Expand Down
Loading
Loading