diff --git a/.githooks/pre-push b/.githooks/pre-push index 3317860f9c..7364ac1ba4 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -15,9 +15,6 @@ cargo fmt --all -- --check echo " [rust] cargo clippy" cargo clippy --workspace --all-targets --features engine/proptest -- -D warnings -echo " [rust] mtgish ordering manifest coverage" -cargo test -p mtgish-import --test manifest_coverage every_list_field_is_in_ordering_manifest - echo " [rust] card-data-validate release check" cargo check --release --bin card-data-validate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3afa4f2a..357b9a568b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: # caught before it lands on the branch that feeds staging/preview. env: PROPTEST_CASES: ${{ github.event_name == 'pull_request' && '32' || '256' }} - run: cargo nextest run --workspace --exclude phase-tauri --features engine/proptest --status-level fail --final-status-level fail + run: cargo nextest run --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest --status-level fail --final-status-level fail card-data-gate: name: Card data (generate, validate, coverage) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 072852c64e..6dc7d80090 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -450,6 +450,8 @@ jobs: - name: Build and push release image uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_RECORD_UPLOAD: false with: context: . push: true diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 18a534389f..96f7676180 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -638,6 +638,14 @@ export interface GameObject { available_mana_pips?: ManaPip[]; casting_permissions?: CastingPermission[]; is_emblem?: boolean; + /** + * CR 111.1: Whether this object is a token (not a card). Independent of + * `display_source`: a token-copy of a real card (Twinflame, Helm of the + * Host) carries `is_token = true` AND `display_source = "Card"`, so it + * renders visually identical to the printed card. Combine the two to flag + * such copies (`is_token && display_source !== "Token"`). + */ + is_token?: boolean; /** * Image-lookup routing hint from the engine. "Card" → look up the image * in the real-card database (default; also covers token-copies of real @@ -1012,7 +1020,19 @@ export type WaitingFor = | { type: "ExileFromBattlefieldForManaAbility"; data: { player: PlayerId; count: number; permanents: ObjectId[]; pending_mana_ability: unknown } } | { type: "SacrificeForManaAbility"; data: { player: PlayerId; count: number; permanents: ObjectId[]; pending_mana_ability: unknown } } | { type: "PayManaAbilityMana"; data: { player: PlayerId; options: ManaType[][]; pending_mana_ability: unknown } } - | { type: "ChooseManaColor"; data: { player: PlayerId; choice: ManaChoicePrompt; context: unknown } } + | { + type: "ChooseManaColor"; + data: { + player: PlayerId; + choice: ManaChoicePrompt; + // CR 605.3a: Only the ManaAbility context carries the bulk-activation + // siblings the UI reads (omitted from the wire when empty). The heavy + // PendingManaAbility / ResolvedAbility payloads stay opaque here. + context: + | { type: "ManaAbility"; data: { batch_siblings?: ObjectId[] } } + | { type: "ResolvingEffect"; data: unknown }; + }; + } | { type: "TapCreaturesForSpellCost"; data: { player: PlayerId; count: number; creatures: ObjectId[]; pending_cast: PendingCast } } | { type: "ExileForCost"; data: { player: PlayerId; zone: ExileCostSourceZone; count: number; cards: ObjectId[]; pending_cast: PendingCast } } | { type: "CollectEvidenceChoice"; data: { player: PlayerId; minimum_mana_value: number; cards: ObjectId[]; resume: unknown } } @@ -1370,7 +1390,7 @@ export type GameAction = | { type: "ChooseX"; data: { value: number } } | { type: "SubmitPayAmount"; data: { amount: number } } | { type: "SubmitPhyrexianChoices"; data: { choices: ShardChoice[] } } - | { type: "ChooseManaColor"; data: { choice: ManaChoice } } + | { type: "ChooseManaColor"; data: { choice: ManaChoice; count?: number } } | { type: "PayManaAbilityMana"; data: { payment: ManaType[] } } | { type: "CastPreparedCopy"; data: { source: ObjectId } } | { type: "CastParadigmCopy"; data: { source: ObjectId } } diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index cef8753962..4cd201b4e8 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -313,6 +313,16 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments // PlayerArea; both can be active independently. const isPhasedOut = obj.phase_status?.status === "PhasedOut"; + // CR 707.2: A token-copy of a real card (Twinflame, Helm of the Host, or a + // debug `CreateTokenCopy`) is `is_token` yet keeps `display_source = "Card"`, + // so it renders pixel-identical to the printed permanent. Flag it so the + // board carries a "Copy" badge — generic tokens (Treasure, Goblin) already + // read as tokens via their distinct generic-token art and are excluded. + // CR 708.2: a face-down permanent has no characteristics other than those + // its face-down rule grants, so never surface "Copy" on it — that would leak + // that it's a token-copy (matches the `!face_down` guard on the keyword strip). + const isCopy = obj.is_token === true && obj.display_source !== "Token" && !obj.face_down; + // Filter out loyalty counters — shown separately as the loyalty badge const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty"); @@ -627,6 +637,21 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments )} + {/* CR 707.2: "Copy" badge for token-copies of real cards — these are + pixel-identical to the printed permanent, so without this tag there's + no way to tell a copy apart from the original on the board. Hidden + while the card is a valid target (the lime "Target" tag owns the + corner during targeting) and shifted down under attack to clear the + ⚔ badge — same coordination the Target tag uses. */} + {isCopy && !isValidTarget && ( +
+ Copy +
+ )} + {/* Debug-panel preview highlight — fuchsia neon ring + animated pulse. Triggered when an ObjectSelect option in the debug panel is hovered (`debugHighlightedObjectId` state). Deliberately loud and visually diff --git a/client/src/components/chrome/DebugActions.tsx b/client/src/components/chrome/DebugActions.tsx index 4461c400b8..79507fee2e 100644 --- a/client/src/components/chrome/DebugActions.tsx +++ b/client/src/components/chrome/DebugActions.tsx @@ -68,14 +68,15 @@ export function DebugActions() {
diff --git a/client/src/components/chrome/DebugCreateActions.tsx b/client/src/components/chrome/DebugCreateActions.tsx index 7348fa2ab4..b4d2a82a1e 100644 --- a/client/src/components/chrome/DebugCreateActions.tsx +++ b/client/src/components/chrome/DebugCreateActions.tsx @@ -644,6 +644,41 @@ function CustomTokenForm({ onDispatch }: Props) { ); } +// Copy an existing permanent via the engine's real CR 707.2 copy-token +// resolver (`Effect::CopyTokenOf`). The engine already owns every nuance — +// copiable-value snapshotting, legendary-rule SBAs, ETB triggers — so this +// form is a thin source+owner picker over the `CreateTokenCopy` debug action. +function CopyPermanentForm({ onDispatch }: Props) { + const [sourceId, setSourceId] = useState(null); + const [owner, setOwner] = useState(0); + + return ( + <> + obj.zone === "Battlefield"} + label="Copy Of" + placeholder="Pick a permanent…" + /> + + + + { + if (sourceId == null) return; + onDispatch({ type: "CreateTokenCopy", data: { source_id: sourceId, owner } }); + }} + disabled={sourceId == null} + > + Create Copy + + + ); +} + export function DebugCreateActions({ onDispatch }: Props) { const { expanded, toggle } = useAccordion(); @@ -658,6 +693,9 @@ export function DebugCreateActions({ onDispatch }: Props) { toggle("token-custom")}> + toggle("copy")}> + +
); } diff --git a/client/src/components/chrome/DebugPanel.tsx b/client/src/components/chrome/DebugPanel.tsx index 90f1c6351b..07047fc1e5 100644 --- a/client/src/components/chrome/DebugPanel.tsx +++ b/client/src/components/chrome/DebugPanel.tsx @@ -119,7 +119,10 @@ export function DebugPanel() { const [showJumpToBottom, setShowJumpToBottom] = useState(false); const prevSnapshotLenRef = useRef(0); - const [activeTab, setActiveTab] = useState<"console" | "actions">("console"); + // Tab lives in uiStore so external entry points (Sandbox Tools nudge/button) + // can open the panel straight to "actions" via `openSandboxTools()`. + const activeTab = useUiStore((s) => s.debugPanelTab); + const setActiveTab = useUiStore((s) => s.setDebugPanelTab); const canRestoreCheckpoints = gameMode === "ai" || gameMode === "local"; const handleRestore = useCallback(async (state: GameState) => { diff --git a/client/src/components/chrome/GameMenu.tsx b/client/src/components/chrome/GameMenu.tsx index 6eff567da9..eb325f060d 100644 --- a/client/src/components/chrome/GameMenu.tsx +++ b/client/src/components/chrome/GameMenu.tsx @@ -17,6 +17,11 @@ interface GameMenuProps { onSettingsClick: () => void; onHelpClick: () => void; onConcede?: () => void; + /** Show the always-visible Sandbox Tools button. Gated by the caller to + * game modes where debug actions actually work (vs-AI, local, or a + * multiplayer sandbox). */ + showSandboxTools?: boolean; + onSandboxToolsClick?: () => void; } export function GameMenu({ @@ -28,6 +33,8 @@ export function GameMenu({ onSettingsClick, onHelpClick, onConcede, + showSandboxTools, + onSandboxToolsClick, }: GameMenuProps) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -77,6 +84,29 @@ export function GameMenu({ + {showSandboxTools && onSandboxToolsClick && ( + + )} {isOnlineMode && } {open && ( diff --git a/client/src/components/help/SandboxToolsNudge.tsx b/client/src/components/help/SandboxToolsNudge.tsx new file mode 100644 index 0000000000..6db7fdd580 --- /dev/null +++ b/client/src/components/help/SandboxToolsNudge.tsx @@ -0,0 +1,43 @@ +import { usePreferencesStore } from "../../stores/preferencesStore.ts"; +import { useUiStore } from "../../stores/uiStore.ts"; + +/** + * First-run nudge that introduces the Sandbox Tools panel (the engine's debug + * actions). In vs-AI and local games the panel is always live, so a new player + * can set up any board state — add cards, tokens, counters, life, copy + * permanents, jump phases. Mirrors {@link FlowHelpNudge}: one-time, dismissible, + * persisted via `preferencesStore.dismissedSandboxToolsNudge`. + */ +export function SandboxToolsNudge() { + const openSandboxTools = useUiStore((s) => s.openSandboxTools); + const setDismissed = usePreferencesStore((s) => s.setDismissedSandboxToolsNudge); + + return ( +
+

+ Set up any board state with Sandbox Tools — add cards + and tokens, change life and counters, copy permanents, or jump phases. Open it anytime with the{" "} + ` key. +

+
+ + +
+
+ ); +} diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index 6ad1137847..662caa78ad 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -3044,7 +3044,18 @@ function ManaColorChoiceModal({ data }: { data: ChooseManaColor["data"] }) { /> ); } - return ; + // CR 605.3a: When the source is a mana ability with identical, choice-free + // twins (the player's other Treasures, etc.), the engine reports them in + // `context.batch_siblings`. Offer a quantity stepper so one color choice can + // bulk-activate up to `siblings + 1` sources. `+ 1` counts the just-tapped + // source already paid for before this prompt. + const batchMax = + data.context.type === "ManaAbility" + ? (data.context.data.batch_siblings?.length ?? 0) + 1 + : 1; + return ( + + ); } function PayManaAbilityManaModal({ data }: { data: PayManaAbilityMana["data"] }) { @@ -3058,26 +3069,43 @@ function PayManaAbilityManaModal({ data }: { data: PayManaAbilityMana["data"] }) ); } -function ManaSingleColorChoiceModal({ options }: { options: ManaType[] }) { +function ManaSingleColorChoiceModal({ + options, + batchMax = 1, +}: { + options: ManaType[]; + batchMax?: number; +}) { const dispatch = useGameDispatch(); const [selected, setSelected] = useState(null); + // CR 605.3a: how many identical sources to activate with the chosen color. + const [count, setCount] = useState(1); const handleConfirm = useCallback(() => { if (selected) { dispatch({ type: "ChooseManaColor", - data: { choice: { type: "SingleColor", data: selected } }, + data: { choice: { type: "SingleColor", data: selected }, count }, }); } - }, [dispatch, selected]); + }, [dispatch, selected, count]); + + const canBatch = batchMax > 1; + const confirmLabel = selected && count > 1 ? `Add ${count}` : "Confirm"; return ( } + footer={ + + } >
{options.map((color, index) => { @@ -3099,6 +3127,35 @@ function ManaSingleColorChoiceModal({ options }: { options: ManaType[] }) { ); })}
+ {canBatch && ( +
+ How many? +
+ + + {count} + + + / {batchMax} +
+
+ )}
); } diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 9c897199d1..6eff5dbf98 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -36,6 +36,7 @@ import { MobileHandDrawer } from "../components/hand/MobileHandDrawer.tsx"; import { HandBadge } from "../components/hand/HandBadge.tsx"; import { PlayerHand } from "../components/hand/PlayerHand.tsx"; import { FlowHelpNudge } from "../components/help/FlowHelpNudge.tsx"; +import { SandboxToolsNudge } from "../components/help/SandboxToolsNudge.tsx"; import { HelpSheet } from "../components/help/HelpSheet.tsx"; import { GameLogPanel } from "../components/log/GameLogPanel.tsx"; import { ChooseXValueUI } from "../components/mana/ChooseXValueUI.tsx"; @@ -677,6 +678,8 @@ function GamePageContent({ const helpSheetOpen = useUiStore((s) => s.helpSheetOpen); const setHelpSheetOpen = useUiStore((s) => s.setHelpSheetOpen); const dismissedFlowHelpNudge = usePreferencesStore((s) => s.dismissedFlowHelpNudge); + const dismissedSandboxToolsNudge = usePreferencesStore((s) => s.dismissedSandboxToolsNudge); + const debugPanelOpen = useUiStore((s) => s.debugPanelOpen); const opponentDisplayName = useMultiplayerStore((s) => s.opponentDisplayName); const adapter = useGameStore((s) => s.adapter); const focusedOpponent = useUiStore((s) => s.focusedOpponent); @@ -898,6 +901,29 @@ function GamePageContent({ canActForWaitingState && stackLength === 0; + // Sequenced after the flow nudge (requires it dismissed first) so the two + // first-run hints never stack. Same calm-moment guards as the flow nudge, + // plus: hidden once the panel is already open (nothing left to advertise). + const showSandboxToolsNudge = + !dismissedSandboxToolsNudge && + dismissedFlowHelpNudge && + !debugPanelOpen && + !helpSheetOpen && + (mode === "ai" || mode === "local") && + viewingZone == null && + preferencesOpen == null && + boardContextMenu == null && + !showCardDataMissing && + resumeResetReason == null && + !showConcedeDialog && + disconnectChoice == null && + pauseReason == null && + reconnectState.status === "idle" && + waitingFor?.type === "Priority" && + waitingFor.data.player === playerId && + canActForWaitingState && + stackLength === 0; + return (
{showFlowHelpNudge && } + {showSandboxToolsNudge && }
@@ -1080,6 +1107,8 @@ function GamePageContent({ onSettingsClick={() => setPreferencesOpen({})} onHelpClick={() => setHelpSheetOpen(true)} onConcede={onShowConcedeDialog} + showSandboxTools={mode === "ai" || mode === "local" || isSandboxGame} + onSandboxToolsClick={() => useUiStore.getState().openSandboxTools()} /> diff --git a/client/src/stores/preferencesStore.ts b/client/src/stores/preferencesStore.ts index b954c27af3..1238e31f8e 100644 --- a/client/src/stores/preferencesStore.ts +++ b/client/src/stores/preferencesStore.ts @@ -126,6 +126,7 @@ function buildDefaultPreferences(): PreferencesState { lastPlayerCount: 2, experimentalFeatures: false, dismissedFlowHelpNudge: false, + dismissedSandboxToolsNudge: false, artChain: [] as ArtChainEntry[], artOverrides: {} as Record, }; @@ -173,6 +174,7 @@ interface PreferencesState { lastPlayerCount: number; experimentalFeatures: boolean; dismissedFlowHelpNudge: boolean; + dismissedSandboxToolsNudge: boolean; artChain: ArtChainEntry[]; artOverrides: Record; } @@ -222,6 +224,7 @@ interface PreferencesActions { setLastPlayerCount: (count: number) => void; setExperimentalFeatures: (enabled: boolean) => void; setDismissedFlowHelpNudge: (dismissed: boolean) => void; + setDismissedSandboxToolsNudge: (dismissed: boolean) => void; addArtChainEntry: (entry: ArtChainEntry) => void; removeArtChainEntry: (index: number) => void; moveArtChainEntry: (fromIndex: number, toIndex: number) => void; @@ -349,6 +352,7 @@ export const usePreferencesStore = create setLastPlayerCount: (count) => set({ lastPlayerCount: count }), setExperimentalFeatures: (enabled) => set({ experimentalFeatures: enabled }), setDismissedFlowHelpNudge: (dismissed) => set({ dismissedFlowHelpNudge: dismissed }), + setDismissedSandboxToolsNudge: (dismissed) => set({ dismissedSandboxToolsNudge: dismissed }), addArtChainEntry: (entry) => set((state) => { const isDuplicate = state.artChain.some((e) => diff --git a/client/src/stores/uiStore.ts b/client/src/stores/uiStore.ts index 81c17808d9..6808738272 100644 --- a/client/src/stores/uiStore.ts +++ b/client/src/stores/uiStore.ts @@ -46,6 +46,10 @@ interface UiStoreState { enchantmentsDialogPlayer: number | null; mobileHandOpen: boolean; debugPanelOpen: boolean; + /** Which top-level tab the debug panel shows. Lifted out of DebugPanel's + * local state so entry points (Sandbox Tools nudge/button) can open the + * panel straight to "actions" instead of the default "console" log view. */ + debugPanelTab: "console" | "actions"; debugInteractionMode: boolean; debugContextMenu: { objectId: ObjectId; x: number; y: number } | null; helpSheetOpen: boolean; @@ -88,6 +92,9 @@ interface UiStoreActions { setEnchantmentsDialogPlayer: (id: number | null) => void; setMobileHandOpen: (open: boolean) => void; toggleDebugPanel: () => void; + setDebugPanelTab: (tab: "console" | "actions") => void; + /** Open the debug panel directly to the Actions ("Sandbox Tools") tab. */ + openSandboxTools: () => void; toggleDebugInteractionMode: () => void; openDebugContextMenu: (menu: { objectId: ObjectId; x: number; y: number }) => void; closeDebugContextMenu: () => void; @@ -126,6 +133,7 @@ export const useUiStore = create()((set) => ({ enchantmentsDialogPlayer: null, mobileHandOpen: false, debugPanelOpen: false, + debugPanelTab: "console", debugInteractionMode: false, debugContextMenu: null, helpSheetOpen: false, @@ -270,6 +278,8 @@ export const useUiStore = create()((set) => ({ setEnchantmentsDialogPlayer: (id) => set({ enchantmentsDialogPlayer: id }), setMobileHandOpen: (open) => set({ mobileHandOpen: open }), toggleDebugPanel: () => set((state) => ({ debugPanelOpen: !state.debugPanelOpen })), + setDebugPanelTab: (tab) => set({ debugPanelTab: tab }), + openSandboxTools: () => set({ debugPanelOpen: true, debugPanelTab: "actions" }), toggleDebugInteractionMode: () => set((state) => ({ debugInteractionMode: !state.debugInteractionMode, debugContextMenu: null, diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 1d2f7a6031..d237f5180d 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -625,6 +625,7 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec { candidate( GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(color), + count: 1, }, TacticalClass::Mana, Some(*player), @@ -637,6 +638,7 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec { candidate( GameAction::ChooseManaColor { choice: ManaChoice::Combination(combo.clone()), + count: 1, }, TacticalClass::Mana, Some(*player), @@ -652,6 +654,7 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec { candidate( GameAction::ChooseManaColor { choice: ManaChoice::Combination(combo), + count: 1, }, TacticalClass::Mana, Some(*player), diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 4be768a4e0..f0e04eade6 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -1017,6 +1017,22 @@ pub(super) fn player_can_spend_as_any_color_for_optional_spell( }) } +pub(super) fn player_can_spend_as_any_color_for_payment( + state: &GameState, + player: PlayerId, + source_id: ObjectId, + ctx: Option<&PaymentContext<'_>>, +) -> bool { + if matches!( + ctx, + Some(PaymentContext::Effect | PaymentContext::Activation { .. }) + ) { + super::static_abilities::player_can_spend_as_any_color(state, player) + } else { + player_can_spend_as_any_color_for_spell(state, player, source_id) + } +} + /// CR 601.2a + CR 611.2a: Check if an object has an alt-cost cast-from-exile /// permission that authorizes this player and satisfies offer-time constraints. fn has_alt_cost_permission_for( @@ -5390,7 +5406,7 @@ fn can_pay_mana_cost_after_auto_tap_with_context( // castable while the real cast failed "Cannot pay mana cost"). super::triggers::resolve_tap_mana_triggers_inline(&mut simulated, &mut tap_events, 0); - let any_color = player_can_spend_as_any_color_for_spell(&simulated, player, source_id); + let any_color = player_can_spend_as_any_color_for_payment(&simulated, player, source_id, ctx); // CR 107.4f + CR 118.1 + CR 118.3 + CR 119.8: Bundle the payer's // payment-time permissions (`any_color`, `max_life`, `life_colors`) so // K'rrik-style life-for-{B} grants are visible to the affordability check. @@ -5539,6 +5555,17 @@ fn requires_untapped(cost: &AbilityCost) -> bool { } } +pub(super) fn ability_mana_payment_excluded_sources( + cost: &AbilityCost, + source_id: ObjectId, +) -> HashSet { + if requires_untapped(cost) { + HashSet::from([source_id]) + } else { + HashSet::new() + } +} + /// Pay a mana cost by auto-tapping lands and deducting from the player's mana pool. /// /// Used by spell casting (`pay_and_push`). Builds a `PaymentContext::Spell` from @@ -5726,6 +5753,26 @@ pub(super) fn pay_ability_mana_cost_excluding( cost: &crate::types::mana::ManaCost, events: &mut Vec, excluded_sources: &HashSet, +) -> Result<(), EngineError> { + pay_ability_mana_cost_with_choices_excluding( + state, + player, + source_id, + cost, + None, + events, + excluded_sources, + ) +} + +pub(super) fn pay_ability_mana_cost_with_choices_excluding( + state: &mut GameState, + player: PlayerId, + source_id: ObjectId, + cost: &crate::types::mana::ManaCost, + phyrexian_choices: Option<&[crate::types::game_state::ShardChoice]>, + events: &mut Vec, + excluded_sources: &HashSet, ) -> Result<(), EngineError> { if state.layers_dirty { super::layers::evaluate_layers(state); @@ -5743,7 +5790,7 @@ pub(super) fn pay_ability_mana_cost_excluding( source_id, cost, Some(&activation_ctx), - None, + phyrexian_choices, events, excluded_sources, )?; @@ -5889,7 +5936,7 @@ fn auto_tap_and_pay_cost_excluding( // (`any_color`, `max_life`, `life_colors`) once for the cast — K'rrik-style // life-for-{B} grants flow through the same dry-run + execution helpers. let permissions = { - let any_color = player_can_spend_as_any_color_for_spell(state, player, source_id); + let any_color = player_can_spend_as_any_color_for_payment(state, player, source_id, ctx); super::static_abilities::build_cost_permission_context(state, player, any_color) }; { @@ -6032,6 +6079,18 @@ pub fn pay_ability_cost( source_id: ObjectId, cost: &AbilityCost, events: &mut Vec, +) -> Result<(), EngineError> { + let excluded_sources = ability_mana_payment_excluded_sources(cost, source_id); + pay_ability_cost_inner(state, player, source_id, cost, events, &excluded_sources) +} + +fn pay_ability_cost_inner( + state: &mut GameState, + player: PlayerId, + source_id: ObjectId, + cost: &AbilityCost, + events: &mut Vec, + excluded_sources: &HashSet, ) -> Result<(), EngineError> { match cost { AbilityCost::Tap => { @@ -6060,11 +6119,29 @@ pub fn pay_ability_cost( // CR 106.6: Ability activation — restriction enforcement routes // through `allows_activation` (not `allows_spell`) via the // activation context built from the source permanent's types. - pay_ability_mana_cost(state, player, source_id, cost, events)?; + if excluded_sources.is_empty() { + pay_ability_mana_cost(state, player, source_id, cost, events)?; + } else { + pay_ability_mana_cost_excluding( + state, + player, + source_id, + cost, + events, + excluded_sources, + )?; + } } AbilityCost::Composite { costs } => { for sub_cost in costs { - pay_ability_cost(state, player, source_id, sub_cost, events)?; + pay_ability_cost_inner( + state, + player, + source_id, + sub_cost, + events, + excluded_sources, + )?; } } AbilityCost::PayLife { amount } => { @@ -8033,6 +8110,24 @@ mod tests { } } + fn add_restricted_mana( + state: &mut GameState, + player: PlayerId, + color: ManaType, + restrictions: Vec, + ) { + let player_data = state.players.iter_mut().find(|p| p.id == player).unwrap(); + player_data.mana_pool.add(ManaUnit { + color, + source_id: ObjectId(0), + snow: false, + source_could_produce_two_or_more_colors: false, + restrictions, + grants: vec![], + expiry: None, + }); + } + fn add_activation_only_colorless_source( state: &mut GameState, card_id: CardId, @@ -9076,6 +9171,488 @@ mod tests { obj_id } + fn colorless_tap_mana_ability() -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap) + } + + fn blue_tap_mana_ability() -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Blue], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap) + } + + fn colorless_activation_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Colorless], + generic: 0, + } + } + + fn create_colorless_tap_activated_source( + state: &mut GameState, + player: PlayerId, + cost: AbilityCost, + effect: Effect, + ) -> ObjectId { + let obj_id = create_object( + state, + CardId(52), + player, + "Mirrorlake Stand-In".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&obj_id).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + Arc::make_mut(&mut obj.abilities).push(colorless_tap_mana_ability()); + Arc::make_mut(&mut obj.abilities) + .push(AbilityDefinition::new(AbilityKind::Activated, effect).cost(cost)); + obj_id + } + + #[test] + fn composite_mana_tap_activation_excludes_source_from_auto_tap() { + let mut state = setup_game_at_main_phase(); + let cost = AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: colorless_activation_mana_cost(), + }, + AbilityCost::Tap, + ], + }; + let source = create_colorless_tap_activated_source( + &mut state, + PlayerId(0), + cost, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + + assert!(!can_activate_ability_now(&state, PlayerId(0), source, 1)); + + let mut events = Vec::new(); + let result = handle_activate_ability(&mut state, PlayerId(0), source, 1, &mut events); + + assert!(result.is_err()); + assert!(!state.objects[&source].tapped); + assert!(events.is_empty()); + } + + #[test] + fn composite_mana_tap_sacrifice_activation_uses_alternate_mana_source() { + let mut state = setup_game_at_main_phase(); + let source_cost = AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: colorless_activation_mana_cost(), + }, + AbilityCost::Tap, + AbilityCost::Sacrifice { + target: TargetFilter::SelfRef, + count: 1, + }, + ], + }; + let source = create_colorless_tap_activated_source( + &mut state, + PlayerId(0), + source_cost, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + let alternate = add_brushland_like_land(&mut state, CardId(53), "Colorless Helper", false); + + assert!(can_activate_ability_now(&state, PlayerId(0), source, 1)); + + let mut events = Vec::new(); + let waiting = + handle_activate_ability(&mut state, PlayerId(0), source, 1, &mut events).unwrap(); + + assert!(matches!(waiting, WaitingFor::Priority { .. })); + assert!(state.objects[&source].tapped); + assert_eq!(state.objects[&source].zone, Zone::Graveyard); + assert!(state.objects[&alternate].tapped); + assert_eq!(state.stack.len(), 1); + } + + #[test] + fn targeted_composite_mana_tap_activation_excludes_source_after_target_selection() { + let mut state = setup_game_at_main_phase(); + let cost = AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: colorless_activation_mana_cost(), + }, + AbilityCost::Tap, + ], + }; + let source = create_colorless_tap_activated_source( + &mut state, + PlayerId(0), + cost, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + damage_source: None, + }, + ); + let target = create_object( + &mut state, + CardId(54), + PlayerId(1), + "Target Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + let other_target = create_object( + &mut state, + CardId(55), + PlayerId(1), + "Other Target Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&other_target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let waiting = + handle_activate_ability(&mut state, PlayerId(0), source, 1, &mut Vec::new()).unwrap(); + assert!(matches!(waiting, WaitingFor::TargetSelection { .. })); + state.waiting_for = waiting; + + let result = handle_select_targets( + &mut state, + PlayerId(0), + vec![TargetRef::Object(target)], + &mut Vec::new(), + ); + + assert!(result.is_err()); + assert!(!state.objects[&source].tapped); + } + + #[test] + fn x_composite_mana_tap_activation_rejects_source_only_pending_payment_without_tapping() { + use super::super::engine::apply_as_current; + use crate::types::ability::QuantityRef; + + let mut state = setup_game_at_main_phase(); + let source = create_colorless_tap_activated_source( + &mut state, + PlayerId(0), + AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + }, + AbilityCost::Tap, + ], + }, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + ); + + apply_as_current( + &mut state, + GameAction::ActivateAbility { + source_id: source, + ability_index: 1, + }, + ) + .unwrap(); + match state.waiting_for.clone() { + WaitingFor::ChooseXValue { max, .. } => assert_eq!( + max, 0, + "the activation source cannot raise X while it must remain untapped for {{T}}" + ), + other => panic!("expected ChooseXValue, got {other:?}"), + } + + let result = apply_as_current(&mut state, GameAction::ChooseX { value: 1 }); + + assert!(result.is_err()); + assert!( + !state.objects[&source].tapped, + "failed pending X payment must not spend the source before its Tap cost" + ); + } + + #[test] + fn x_composite_mana_tap_activation_excludes_source_during_pending_payment() { + use super::super::engine::apply_as_current; + use crate::types::ability::QuantityRef; + + let mut state = setup_game_at_main_phase(); + let source = create_colorless_tap_activated_source( + &mut state, + PlayerId(0), + AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + }, + AbilityCost::Tap, + ], + }, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + ); + let alternate = add_brushland_like_land(&mut state, CardId(56), "X Helper", false); + + apply_as_current( + &mut state, + GameAction::ActivateAbility { + source_id: source, + ability_index: 1, + }, + ) + .unwrap(); + assert!( + matches!(state.waiting_for, WaitingFor::ChooseXValue { .. }), + "X activation should ask for X before payment" + ); + assert!( + !state.objects[&source].tapped, + "source must stay untapped before pending mana payment" + ); + + apply_as_current(&mut state, GameAction::ChooseX { value: 1 }).unwrap(); + + assert!(state.objects[&source].tapped); + assert!( + state.objects[&alternate].tapped, + "pending activation mana payment must use the alternate source" + ); + assert_eq!(state.stack.len(), 1); + } + + #[test] + fn x_phyrexian_composite_mana_tap_activation_excludes_source_during_phyrexian_prepass() { + use super::super::engine::apply_as_current; + use crate::types::ability::QuantityRef; + + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(57), + PlayerId(0), + "Phyrexian Mirrorlake Stand-In".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&source).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + Arc::make_mut(&mut obj.abilities).push(blue_tap_mana_ability()); + Arc::make_mut(&mut obj.abilities).push( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::PhyrexianBlue], + generic: 0, + }, + }, + AbilityCost::Tap, + ], + }), + ); + } + let life_before = state.players[0].life; + + apply_as_current( + &mut state, + GameAction::ActivateAbility { + source_id: source, + ability_index: 1, + }, + ) + .unwrap(); + + match state.waiting_for.clone() { + WaitingFor::ChooseXValue { max, .. } => assert_eq!( + max, 0, + "the activation source's own blue mana ability cannot pay X while {{T}} remains due" + ), + other => panic!("expected ChooseXValue, got {other:?}"), + } + + apply_as_current(&mut state, GameAction::ChooseX { value: 0 }).unwrap(); + + assert!( + !matches!(state.waiting_for, WaitingFor::PhyrexianPayment { .. }), + "source-only Phyrexian activation has no mana/life choice once the source is excluded" + ); + if matches!(state.waiting_for, WaitingFor::ManaPayment { .. }) { + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + } + + assert!(state.objects[&source].tapped); + assert_eq!(state.players[0].life, life_before - 2); + assert_eq!(state.stack.len(), 1); + } + + #[test] + fn x_phyrexian_composite_mana_tap_activation_keeps_source_untapped_until_choice_resume() { + use super::super::engine::apply_as_current; + use crate::types::ability::QuantityRef; + use crate::types::game_state::{ShardChoice, ShardOptions}; + + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(58), + PlayerId(0), + "Choice Phyrexian Mirrorlake Stand-In".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&source).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + Arc::make_mut(&mut obj.abilities).push(blue_tap_mana_ability()); + Arc::make_mut(&mut obj.abilities).push( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::PhyrexianBlue], + generic: 0, + }, + }, + AbilityCost::Tap, + ], + }), + ); + } + add_mana(&mut state, PlayerId(0), ManaType::Blue, 1); + let life_before = state.players[0].life; + + apply_as_current( + &mut state, + GameAction::ActivateAbility { + source_id: source, + ability_index: 1, + }, + ) + .unwrap(); + apply_as_current(&mut state, GameAction::ChooseX { value: 0 }).unwrap(); + + assert!(matches!( + state.waiting_for, + WaitingFor::ManaPayment { + player: PlayerId(0), + convoke_mode: None + } + )); + assert!( + !state.objects[&source].tapped, + "source must remain untapped before pending mana payment finalizes" + ); + + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + + match state.waiting_for.clone() { + WaitingFor::PhyrexianPayment { shards, .. } => { + assert_eq!(shards.len(), 1); + assert!(matches!(shards[0].options, ShardOptions::ManaOrLife)); + } + other => panic!("expected PhyrexianPayment, got {other:?}"), + } + assert!( + !state.objects[&source].tapped, + "source must remain untapped while the Phyrexian mana choice is pending" + ); + + apply_as_current( + &mut state, + GameAction::SubmitPhyrexianChoices { + choices: vec![ShardChoice::PayMana], + }, + ) + .unwrap(); + + assert!(state.objects[&source].tapped); + assert_eq!(state.players[0].life, life_before); + assert_eq!(state.players[0].mana_pool.total(), 0); + assert_eq!(state.stack.len(), 1); + } + #[test] fn spell_cast_from_hand_moves_to_stack() { let mut state = setup_game_at_main_phase(); @@ -19721,6 +20298,44 @@ mod tests { ); } + #[test] + fn phyrexian_spell_prepass_honors_spell_mana_restrictions() { + let mut state = setup_game_at_main_phase(); + let spell = create_phyrexian_instant_in_hand( + &mut state, + PlayerId(0), + vec![ManaCostShard::PhyrexianBlue], + 0, + ); + add_restricted_mana( + &mut state, + PlayerId(0), + ManaType::Blue, + vec![ManaRestriction::OnlyForSpellType("Creature".to_string())], + ); + let life_before = state.players[0].life; + + let waiting = handle_cast_spell( + &mut state, + PlayerId(0), + spell, + CardId(0x9117), + &mut Vec::new(), + ) + .expect("restricted mana cannot pay this instant, but life can"); + + assert!( + !matches!(waiting, WaitingFor::PhyrexianPayment { .. }), + "spell-restricted mana must not create a false mana/life choice for an ineligible spell" + ); + assert_eq!(state.players[0].life, life_before - 2); + assert_eq!( + state.players[0].mana_pool.total(), + 1, + "ineligible restricted mana must remain in the pool" + ); + } + /// CR 107.4f + CR 118.3b: Multi-Phyrexian cost paid entirely with life — /// each shard deducts 2, total life loss = 2 × shard_count. #[test] diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 063eb5f470..d1477eeef4 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1,9 +1,9 @@ use std::collections::HashSet; use crate::types::ability::{ - AbilityCost, AdditionalCost, BeholdCostAction, CastTimingPermission, CostPaidObjectSnapshot, - Effect, KickerVariant, QuantityExpr, ResolvedAbility, SpellCastingOptionKind, TargetFilter, - TypedFilter, + AbilityCondition, AbilityCost, AdditionalCost, BeholdCostAction, CastTimingPermission, + CostPaidObjectSnapshot, Effect, KickerVariant, QuantityExpr, QuantityRef, ResolvedAbility, + SpellCastingOptionKind, TargetFilter, TypedFilter, }; use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ @@ -32,6 +32,73 @@ use super::ability_utils::{ }; use super::life_costs::PayLifeCostResult; +fn stamp_controller_controlled_as_cast( + state: &GameState, + ability: &mut ResolvedAbility, + player: PlayerId, + source_id: ObjectId, +) { + let mut filters = Vec::new(); + collect_controller_controlled_as_cast_filters(ability, &mut filters); + let mut unique_filters = Vec::new(); + for filter in filters { + if !unique_filters.contains(&filter) { + unique_filters.push(filter); + } + } + ability.context.controller_controlled_as_cast = unique_filters + .into_iter() + .filter(|filter| { + super::quantity::resolve_quantity( + state, + &QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: filter.clone(), + }, + }, + player, + source_id, + ) > 0 + }) + .collect(); +} + +fn collect_controller_controlled_as_cast_filters( + ability: &ResolvedAbility, + filters: &mut Vec, +) { + if let Some(condition) = &ability.condition { + collect_controller_controlled_as_cast_filters_from_condition(condition, filters); + } + if let Some(sub_ability) = &ability.sub_ability { + collect_controller_controlled_as_cast_filters(sub_ability, filters); + } + if let Some(else_ability) = &ability.else_ability { + collect_controller_controlled_as_cast_filters(else_ability, filters); + } +} + +fn collect_controller_controlled_as_cast_filters_from_condition( + condition: &AbilityCondition, + filters: &mut Vec, +) { + match condition { + AbilityCondition::ControllerControlledMatchingAsCast { filter } => { + filters.push(filter.clone()); + } + AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { + for condition in conditions { + collect_controller_controlled_as_cast_filters_from_condition(condition, filters); + } + } + AbilityCondition::Not { condition } + | AbilityCondition::ConditionInstead { inner: condition } => { + collect_controller_controlled_as_cast_filters_from_condition(condition, filters); + } + _ => {} + } +} + /// Handle the player's decision on an additional cost (kicker, blight, "or pay"). /// /// For `Optional`: `pay=true` pays the cost and sets `additional_cost_paid`, `pay=false` skips. @@ -2470,8 +2537,15 @@ pub(super) fn pay_and_push_adventure( // CR 107.4f + CR 601.2f: Pause for interactive Phyrexian choice when the cost has // at least one shard with both mana and 2-life viable. The resume handler calls // `finalize_mana_payment_with_phyrexian_choices` which finishes the cast. - if let Some(waiting) = maybe_pause_for_phyrexian_choice(state, player, object_id, cost, events) - { + if let Some(waiting) = maybe_pause_for_phyrexian_choice( + state, + player, + object_id, + cost, + events, + None, + &HashSet::new(), + ) { let mut pending = PendingCast::new(object_id, card_id, ability, cost.clone()); pending.casting_variant = casting_variant; pending.cast_timing_permission = cast_timing_permission; @@ -2708,6 +2782,7 @@ pub(super) fn finalize_cast_with_phyrexian_choices( let mut ability = ability; ability.context.cast_from_zone = Some(source_zone); ability.context.cast_phase = Some(state.phase); + stamp_controller_controlled_as_cast(state, &mut ability, player, object_id); // Emit targeting events now that the cast is committed. emit_targeting_events( @@ -3733,6 +3808,16 @@ pub fn max_x_value( player: PlayerId, cost: &ManaCost, object_id: Option, +) -> u32 { + max_x_value_excluding(state, player, cost, object_id, &HashSet::new()) +} + +pub(super) fn max_x_value_excluding( + state: &GameState, + player: PlayerId, + cost: &ManaCost, + object_id: Option, + excluded_sources: &HashSet, ) -> u32 { let ManaCost::Cost { shards, generic } = cost else { return 0; @@ -3796,6 +3881,7 @@ pub fn max_x_value( let capacity: u32 = state .battlefield .iter() + .filter(|id| !excluded_sources.contains(id)) .map(|&id| { let mana = mana_sources::max_mana_yield(state, id, player); let tap = pred @@ -3834,7 +3920,20 @@ pub fn enter_payment_step( if let Some(pending) = state.pending_cast.as_ref() { if pending.ability.chosen_x.is_none() && cost_has_x(&pending.cost) { let min = pending.ability.min_x_value; - let max = max_x_value(state, player, &pending.cost, Some(pending.object_id)); + let excluded_sources = pending + .activation_cost + .as_ref() + .map(|cost| { + super::casting::ability_mana_payment_excluded_sources(cost, pending.object_id) + }) + .unwrap_or_default(); + let max = max_x_value_excluding( + state, + player, + &pending.cost, + Some(pending.object_id), + &excluded_sources, + ); if min > max { let pending_for_cancel = pending.clone(); state.pending_cast = None; @@ -3894,11 +3993,45 @@ pub fn finalize_mana_payment( // `PendingCast` stays in `state.pending_cast` across the pause — the resume handler // in `engine.rs` calls `finalize_mana_payment_with_phyrexian_choices`. if let Some(pending_ref) = state.pending_cast.as_ref() { - let cost = pending_ref.cost.clone(); + let mana_cost = pending_ref.cost.clone(); let source_id = pending_ref.object_id; - if let Some(waiting) = - maybe_pause_for_phyrexian_choice(state, player, source_id, &cost, events) - { + if pending_ref.activation_ability_index.is_some() { + let excluded_sources = pending_ref + .activation_cost + .as_ref() + .map(|activation_cost| { + super::casting::ability_mana_payment_excluded_sources( + activation_cost, + source_id, + ) + }) + .unwrap_or_default(); + let (source_types, source_subtypes) = + super::casting::activation_source_types(state, source_id); + let activation_ctx = PaymentContext::Activation { + source_types: &source_types, + source_subtypes: &source_subtypes, + }; + if let Some(waiting) = maybe_pause_for_phyrexian_choice( + state, + player, + source_id, + &mana_cost, + events, + Some(&activation_ctx), + &excluded_sources, + ) { + return Ok(waiting); + } + } else if let Some(waiting) = maybe_pause_for_phyrexian_choice( + state, + player, + source_id, + &mana_cost, + events, + None, + &HashSet::new(), + ) { return Ok(waiting); } } @@ -3909,7 +4042,21 @@ pub fn finalize_mana_payment( .ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?; if let Some(ability_index) = pending.activation_ability_index { - super::casting::pay_mana_cost(state, player, pending.object_id, &pending.cost, events)?; + let excluded_sources = pending + .activation_cost + .as_ref() + .map(|cost| { + super::casting::ability_mana_payment_excluded_sources(cost, pending.object_id) + }) + .unwrap_or_default(); + super::casting::pay_ability_mana_cost_excluding( + state, + player, + pending.object_id, + &pending.cost, + events, + &excluded_sources, + )?; return push_activated_ability_to_stack( state, player, @@ -4044,13 +4191,21 @@ pub fn finalize_mana_payment_with_phyrexian_choices( .ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?; if let Some(ability_index) = pending.activation_ability_index { - super::casting::pay_mana_cost_with_choices( + let excluded_sources = pending + .activation_cost + .as_ref() + .map(|cost| { + super::casting::ability_mana_payment_excluded_sources(cost, pending.object_id) + }) + .unwrap_or_default(); + super::casting::pay_ability_mana_cost_with_choices_excluding( state, player, pending.object_id, &pending.cost, Some(phyrexian_choices), events, + &excluded_sources, )?; return push_activated_ability_to_stack( state, @@ -4171,6 +4326,8 @@ pub(super) fn maybe_pause_for_phyrexian_choice( source_id: ObjectId, cost: &crate::types::mana::ManaCost, events: &mut Vec, + payment_context: Option<&PaymentContext<'_>>, + excluded_sources: &HashSet, ) -> Option { // CR 107.4f: Fast reject — pause only when cost has intrinsic Phyrexian // shards OR the player has a K'rrik-style grant whose color appears in the @@ -4213,15 +4370,35 @@ pub(super) fn maybe_pause_for_phyrexian_choice( // CR 601.2h + CR 605: Auto-tap mana sources before shard-options computation so // the simulation reflects the actual post-tap pool. let events_before = events.len(); - auto_tap_mana_sources(state, player, cost, events, Some(source_id)); + if payment_context.is_none() && excluded_sources.is_empty() { + auto_tap_mana_sources(state, player, cost, events, Some(source_id)); + } else { + auto_tap_mana_sources_with_context_excluding( + state, + player, + cost, + events, + Some(source_id), + payment_context, + excluded_sources, + ); + } // CR 605.4a: Resolve coupled `TapsForMana` triggered mana abilities inline so // the bonus mana is in the pool before Phyrexian shard options are computed. super::triggers::resolve_tap_mana_triggers_inline(state, events, events_before); - let spell_meta = super::casting::build_spell_meta(state, player, source_id); + let spell_meta = payment_context + .is_none() + .then(|| super::casting::build_spell_meta(state, player, source_id)) + .flatten(); let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell); - let any_color = - super::casting::player_can_spend_as_any_color_for_spell(state, player, source_id); + let effective_payment_context = payment_context.or(spell_ctx.as_ref()); + let any_color = super::casting::player_can_spend_as_any_color_for_payment( + state, + player, + source_id, + effective_payment_context, + ); // CR 107.4f + CR 118.1: Single-authority permission bundle — passes // `life_colors` through to `compute_phyrexian_shards` so K'rrik-promoted // shards surface in the pause UI. @@ -4233,7 +4410,7 @@ pub(super) fn maybe_pause_for_phyrexian_choice( mana_payment::compute_phyrexian_shards( &player_data.mana_pool, cost, - spell_ctx.as_ref(), + effective_payment_context, permissions, ) }; @@ -4353,6 +4530,64 @@ mod tests { } } + #[test] + fn stamp_controller_controlled_as_cast_uses_quantity_resolver_snapshot() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Conditional Spell".to_string(), + Zone::Hand, + ); + let faerie_id = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Faerie".to_string(), + Zone::Battlefield, + ); + let faerie = state.objects.get_mut(&faerie_id).unwrap(); + faerie.card_types.core_types.push(CoreType::Creature); + faerie.card_types.subtypes.push("Faerie".to_string()); + + let filter = TargetFilter::Typed( + TypedFilter::creature() + .subtype("Faerie".to_string()) + .controller(ControllerRef::You) + .properties(vec![FilterProp::InZone { + zone: Zone::Battlefield, + }]), + ); + let mut ability = ResolvedAbility::new( + Effect::Scry { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + Vec::new(), + source_id, + PlayerId(0), + ) + .sub_ability( + ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + Vec::new(), + source_id, + PlayerId(0), + ) + .condition(AbilityCondition::ControllerControlledMatchingAsCast { + filter: filter.clone(), + }), + ); + + stamp_controller_controlled_as_cast(&state, &mut ability, PlayerId(0), source_id); + + assert_eq!(ability.context.controller_controlled_as_cast, vec![filter]); + } + #[test] fn activation_one_of_choice_replaces_nested_first_branch() { let mut state = GameState::new_two_player(42); @@ -5725,6 +5960,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Red, ), + count: 1, }, ) .expect("color choice succeeds"); @@ -5863,6 +6099,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Red, ), + count: 1, }, ) .expect("color choice succeeds"); diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 0315310fa2..600fea8795 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -5012,6 +5012,9 @@ fn condition_feature(cond: &AbilityCondition) -> (&'static str, FeatureSupport) AbilityCondition::ControllerControlsMatching { .. } => { ("ControllerControlsMatching", Handled) } + AbilityCondition::ControllerControlledMatchingAsCast { .. } => { + ("ControllerControlledMatchingAsCast", Handled) + } AbilityCondition::ZoneChangeObjectMatchesFilter { .. } => { ("ZoneChangeObjectMatchesFilter", Handled) } diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index 984190a1f4..b0a10e18e5 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -241,7 +241,7 @@ pub fn resolve( events.push(GameEvent::EffectResolved { kind: EffectKind::from(&ability.effect), - source_id: ability.source_id, + source_id: explorer_id, }); return Ok(()); } @@ -278,7 +278,7 @@ pub fn resolve( events.push(GameEvent::EffectResolved { kind: EffectKind::from(&ability.effect), - source_id: ability.source_id, + source_id: explorer_id, }); } else { // CR 701.44a: Nonland revealed — put a +1/+1 counter on the creature, @@ -302,7 +302,7 @@ pub fn resolve( events.push(GameEvent::EffectResolved { kind: EffectKind::from(&ability.effect), - source_id: ability.source_id, + source_id: explorer_id, }); } @@ -563,6 +563,16 @@ mod tests { resolve(&mut state, &ability, &mut events).unwrap(); + assert!( + events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::Explore, + source_id + } if *source_id == target + )), + "explore completion event should identify the exploring permanent" + ); assert_eq!( state.objects[&target].counters[&CounterType::Plus1Plus1], 1, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 4f2fcbc431..f23985406f 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -925,6 +925,7 @@ fn should_resolve_subability_on_optional_decline(ability: &ResolvedAbility) -> b | AbilityCondition::SourceMatchesFilter { .. } | AbilityCondition::ZoneChangeObjectMatchesFilter { .. } | AbilityCondition::ControllerControlsMatching { .. } + | AbilityCondition::ControllerControlledMatchingAsCast { .. } | AbilityCondition::IsYourTurn | AbilityCondition::WasStartingPlayer { .. } | AbilityCondition::SpellCastWithVariantThisTurn { .. } @@ -3482,7 +3483,7 @@ fn resolve_chain_body( /// Returns whether the condition is met. Handles all `AbilityCondition` variants as /// pure boolean evaluators — callers are responsible for any terminal control flow /// (e.g., "Instead" overrides that early-return in the sub-ability context). -fn evaluate_condition( +pub(crate) fn evaluate_condition( condition: &AbilityCondition, state: &GameState, ability: &ResolvedAbility, @@ -3753,6 +3754,13 @@ fn evaluate_condition( && crate::game::filter::matches_target_filter(state, o.id, filter, &ctx) }) } + // CR 601.2 + CR 608.2c: "if you controlled a [filter] as you cast this + // spell" reads the casting snapshot, not the resolution-time battlefield. + AbilityCondition::ControllerControlledMatchingAsCast { filter } => ability + .context + .controller_controlled_as_cast + .iter() + .any(|snapshot_filter| snapshot_filter == filter), // CR 608.2c: "If it's your turn" — check active player against the // scoped player during each-player iteration, otherwise the controller. AbilityCondition::IsYourTurn => { @@ -10058,6 +10066,40 @@ mod tests { )); } + #[test] + fn evaluate_controller_controlled_as_cast_reads_spell_context_snapshot() { + let state = GameState::new_two_player(42); + let filter = TargetFilter::Typed( + TypedFilter::creature() + .subtype("Faerie".to_string()) + .controller(ControllerRef::You) + .properties(vec![FilterProp::InZone { + zone: Zone::Battlefield, + }]), + ); + let condition = AbilityCondition::ControllerControlledMatchingAsCast { + filter: filter.clone(), + }; + let mut ability = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(1), + PlayerId(0), + ) + .context(SpellContext { + controller_controlled_as_cast: vec![filter], + ..SpellContext::default() + }); + + assert!(evaluate_condition(&condition, &state, &ability)); + + ability.context.controller_controlled_as_cast.clear(); + assert!(!evaluate_condition(&condition, &state, &ability)); + } + #[test] fn evaluate_condition_and_one_false() { let state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 5cebf567ec..8ed7c06897 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2013,18 +2013,46 @@ fn apply_action( WaitingFor::ChooseManaColor { choice, context, .. }, - GameAction::ChooseManaColor { choice: chosen }, + GameAction::ChooseManaColor { + choice: chosen, + count, + }, ) => { let events_before = events.len(); let wf = match context { crate::types::game_state::ManaChoiceContext::ManaAbility(pending_mana_ability) => { - engine_casting::handle_choose_mana_color( + // CR 605.3a: validate the requested batch size BEFORE any mana + // is produced, so an out-of-range count rejects cleanly with + // no partial application. The cap is the just-activated source + // plus its choice-free identical twins. + if count as usize > pending_mana_ability.batch_siblings.len() + 1 { + return Err(EngineError::InvalidAction(format!( + "ChooseManaColor count {count} exceeds the {} batchable sources", + pending_mana_ability.batch_siblings.len() + 1 + ))); + } + let wf = engine_casting::handle_choose_mana_color( state, pending_mana_ability, choice, chosen.clone(), &mut events, - )? + )?; + // CR 605.3a: one color choice may bulk-activate the player's + // other identical, choice-free mana sources (their remaining + // Treasures, etc.) with the same color. Sibling cost/mana + // events append before the shared trigger scan below, so each + // sacrifice's observers fire exactly once. + if count > 1 { + engine_casting::batch_activate_mana_siblings( + state, + pending_mana_ability, + &chosen, + count, + &mut events, + )?; + } + wf } crate::types::game_state::ManaChoiceContext::ResolvingEffect(pending_effect) => { effects::mana::handle_choose_mana_effect( @@ -2432,31 +2460,55 @@ fn apply_action( EngineError::InvalidAction("No pending cast for Phyrexian payment".to_string()) })?; let cost = pending_ref.cost.clone(); - let spell_meta = casting::build_spell_meta(state, player, spell_object); - let any_color = - casting::player_can_spend_as_any_color_for_spell(state, player, spell_object); - // CR 107.4f + CR 118.1 + CR 118.3 + CR 119.8: Re-derive the - // payment-permission bundle (any_color + max_life + life_colors) - // so re-validation sees the same K'rrik-promoted shard set the - // pause UI was built from. - let permissions = super::static_abilities::build_cost_permission_context( - state, player, any_color, - ); let player_pool = state .players .iter() .find(|p| p.id == player) .map(|p| p.mana_pool.clone()) .ok_or_else(|| EngineError::InvalidAction("Player not found".to_string()))?; - let spell_ctx = spell_meta - .as_ref() - .map(crate::types::mana::PaymentContext::Spell); - let current_shards = mana_payment::compute_phyrexian_shards( - &player_pool, - &cost, - spell_ctx.as_ref(), - permissions, - ); + let current_shards = if pending_ref.activation_ability_index.is_some() { + let (source_types, source_subtypes) = + casting::activation_source_types(state, spell_object); + let activation_ctx = crate::types::mana::PaymentContext::Activation { + source_types: &source_types, + source_subtypes: &source_subtypes, + }; + let any_color = casting::player_can_spend_as_any_color_for_payment( + state, + player, + spell_object, + Some(&activation_ctx), + ); + let permissions = super::static_abilities::build_cost_permission_context( + state, player, any_color, + ); + mana_payment::compute_phyrexian_shards( + &player_pool, + &cost, + Some(&activation_ctx), + permissions, + ) + } else { + let spell_meta = casting::build_spell_meta(state, player, spell_object); + let spell_ctx = spell_meta + .as_ref() + .map(crate::types::mana::PaymentContext::Spell); + let any_color = casting::player_can_spend_as_any_color_for_payment( + state, + player, + spell_object, + spell_ctx.as_ref(), + ); + let permissions = super::static_abilities::build_cost_permission_context( + state, player, any_color, + ); + mana_payment::compute_phyrexian_shards( + &player_pool, + &cost, + spell_ctx.as_ref(), + permissions, + ) + }; if current_shards.len() != expected_len { return Err(EngineError::ActionNotAllowed( "Phyrexian shard count changed during pause".to_string(), @@ -7473,6 +7525,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Green, ), + count: 1, }, ) .unwrap(); @@ -9224,6 +9277,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Green, ), + count: 1, }, ) .unwrap(); @@ -9351,6 +9405,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Green, ), + count: 1, }, ) .unwrap(); @@ -9521,6 +9576,7 @@ mod tests { choice: crate::types::game_state::ManaChoice::SingleColor( crate::types::mana::ManaType::Green, ), + count: 1, }, ) .unwrap(); @@ -9696,6 +9752,7 @@ mod tests { &mut state, GameAction::ChooseManaColor { choice: crate::types::game_state::ManaChoice::SingleColor(ManaType::Green), + count: 1, }, ) .unwrap(); diff --git a/crates/engine/src/game/engine_casting.rs b/crates/engine/src/game/engine_casting.rs index c3bf3f08ec..ca03ba994f 100644 --- a/crates/engine/src/game/engine_casting.rs +++ b/crates/engine/src/game/engine_casting.rs @@ -230,6 +230,19 @@ pub(super) fn handle_choose_mana_color( mana_abilities::handle_choose_mana_color(state, pending_mana_ability, prompt, chosen, events) } +/// CR 605.3a: Bulk-activate identical, choice-free sibling mana sources with the +/// color just chosen (the player's other Treasures, etc.). Thin forward to the +/// engine authority in `mana_abilities`. +pub(super) fn batch_activate_mana_siblings( + state: &mut GameState, + pending_mana_ability: &PendingManaAbility, + chosen: &crate::types::game_state::ManaChoice, + count: u32, + events: &mut Vec, +) -> Result<(), EngineError> { + mana_abilities::batch_activate_mana_siblings(state, pending_mana_ability, chosen, count, events) +} + pub(super) fn handle_pay_mana_ability_mana( state: &mut GameState, options: &[Vec], diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index f88a5f110f..c2e2e391f0 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1,9 +1,9 @@ use crate::types::ability::{ - AbilityCost, AbilityDefinition, ChoiceValue, CostPaidObjectSnapshot, Effect, ManaProduction, - ResolvedAbility, TargetFilter, + AbilityCondition, AbilityCost, AbilityDefinition, ChoiceValue, CostPaidObjectSnapshot, Effect, + ManaProduction, ResolvedAbility, TargetFilter, }; #[cfg(test)] -use crate::types::counter::{CounterMatch, CounterType}; +use crate::types::counter::CounterMatch; use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ GameState, ManaAbilityResume, ManaChoice, ManaChoiceContext, ManaChoicePrompt, @@ -224,17 +224,19 @@ fn produce_mana_from_ability( // cost-paid object snapshot so quantity resolution sees it. Reused for // both production-count and sub-chain resolution paths so the same // snapshot is visible end-to-end. - let mut resolved_for_quantity = - super::ability_utils::build_resolved_from_def(ability_def, source_id, player); - if let Some(snapshot) = cost_paid_object { - resolved_for_quantity.set_cost_paid_object_recursive(snapshot); - } + let resolved_for_quantity = resolved_mana_ability_for_current_state( + state, + source_id, + player, + ability_def, + cost_paid_object, + ); // CR 106.6: Resolve spend-restriction templates, grants, and expiry so they // attach to each produced `ManaUnit`. Dropping these here is the bug that // made Flamebraider's Elemental-only mana behave as unrestricted mana. let (produced_mana, restrictions, grants, expiry, source_could_produce_two_or_more_colors) = - match &*ability_def.effect { + match &resolved_for_quantity.effect { Effect::Mana { produced, restrictions, @@ -308,7 +310,44 @@ fn produce_mana_from_ability( // effect chain (e.g. painlands' "This land deals 1 damage to you.") // resolves that chain inline — mana abilities don't use the stack, so // the sub-ability runs as part of the same atomic resolution. - resolve_mana_ability_sub_chain(state, source_id, player, ability_def, events); + resolve_mana_ability_sub_chain(state, &resolved_for_quantity, events); +} + +fn resolved_mana_ability_for_current_state( + state: &GameState, + source_id: ObjectId, + player: PlayerId, + ability_def: &AbilityDefinition, + cost_paid_object: Option, +) -> ResolvedAbility { + let mut resolved = + super::ability_utils::build_resolved_from_def(ability_def, source_id, player); + if let Some(snapshot) = cost_paid_object { + resolved.set_cost_paid_object_recursive(snapshot); + } + apply_condition_instead_mana_swap(state, &resolved) +} + +fn apply_condition_instead_mana_swap( + state: &GameState, + ability: &ResolvedAbility, +) -> ResolvedAbility { + let Some(sub) = ability.sub_ability.as_deref() else { + return ability.clone(); + }; + let Some(AbilityCondition::ConditionInstead { inner }) = sub.condition.as_ref() else { + return ability.clone(); + }; + if super::effects::evaluate_condition(inner, state, ability) { + if matches!(sub.effect, Effect::Mana { target: None, .. }) { + return super::ability_utils::apply_instead_swap(ability, sub); + } + return ability.clone(); + } + + let mut base = ability.clone(); + base.sub_ability = sub.else_ability.clone(); + base } fn resolve_single_color_override( @@ -385,6 +424,7 @@ pub fn activate_mana_ability( chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }, events, ) @@ -603,6 +643,80 @@ pub fn handle_choose_mana_color( Ok(resume_waiting_for(pending.player, pending.resume.clone())) } +/// CR 605.3a: Bulk-activate the controller's other identical, choice-free mana +/// sources (their remaining Treasures, etc.) with the color just chosen for a +/// `SingleColor` prompt. Runs immediately after `handle_choose_mana_color` has +/// resolved the originally-tapped source; together they activate `count` sources +/// in one `ChooseManaColor` round-trip. +/// +/// Each sibling is an independent activated mana ability that resolves +/// immediately and before the next is begun (CR 605.3c), without using the stack +/// (CR 605.3b) — so no player gains priority between them. Cost-payment and mana +/// events append to `events`; the caller's single post-handler trigger scan then +/// fires each sacrifice's observers (Mayhem Devil, Korvold, Cruel Celebrant, …) +/// exactly once. `pending.batch_siblings` was pre-filtered to choice-free, +/// currently-activatable twins (see `cost_resolves_without_choice` / +/// `batch_eligible_siblings`), so no sibling can surface a further interactive +/// prompt — that invariant is asserted below rather than handled. +pub(crate) fn batch_activate_mana_siblings( + state: &mut GameState, + pending: &PendingManaAbility, + chosen: &ManaChoice, + count: u32, + events: &mut Vec, +) -> Result<(), EngineError> { + let ManaChoice::SingleColor(color) = chosen else { + return Err(EngineError::InvalidAction( + "Bulk mana activation is only valid for a single-color choice".to_string(), + )); + }; + // `count` is validated against `batch_siblings.len() + 1` by the dispatcher + // before any mana is produced, so `extra` never exceeds the sibling list and + // `take` is exact. + let extra = (count as usize).saturating_sub(1); + + // The originally-activated source's mana ability is the shape every sibling + // was selected to match. Re-resolve each sibling's matching ability index + // (a sibling may carry unrelated abilities too). + let reference_def = state + .objects + .get(&pending.source_id) + .and_then(|obj| obj.abilities.get(pending.ability_index)) + .cloned() + .ok_or_else(|| { + EngineError::InvalidAction("Mana ability source no longer exists".to_string()) + })?; + + for &sibling_id in pending.batch_siblings.iter().take(extra) { + let Some((index, def)) = state.objects.get(&sibling_id).and_then(|obj| { + obj.abilities + .iter() + .position(|ability| *ability == reference_def) + .map(|index| (index, obj.abilities[index].clone())) + }) else { + return Err(EngineError::InvalidAction( + "Bulk mana source is no longer available".to_string(), + )); + }; + // CR 605.3a + CR 605.3b: independent mana ability, no stack, color fixed. + let resume = activate_mana_ability( + state, + sibling_id, + pending.player, + index, + &def, + events, + ManaAbilityResume::Priority, + Some(ProductionOverride::SingleColor(*color)), + )?; + debug_assert!( + matches!(resume, WaitingFor::Priority { .. }), + "batched choice-free mana sibling returned an interactive state: {resume:?}" + ); + } + Ok(()) +} + /// CR 118.3 / CR 605.3b: Complete the tapped-creature choice, then resolve the mana ability. pub fn handle_tap_creatures_for_mana_ability( state: &mut GameState, @@ -784,7 +898,7 @@ pub fn can_activate_mana_ability_now( fn advance_mana_ability_activation( state: &mut GameState, - pending: PendingManaAbility, + mut pending: PendingManaAbility, events: &mut Vec, ) -> Result { let ability_def = state @@ -921,16 +1035,15 @@ fn advance_mana_ability_activation( } if pending.color_override.is_none() { - let mut resolved_for_prompt = super::ability_utils::build_resolved_from_def( - &ability_def, + let resolved_for_prompt = resolved_mana_ability_for_current_state( + state, pending.source_id, pending.player, + &ability_def, + pending.cost_paid_object.clone(), ); - if let Some(snapshot) = pending.cost_paid_object.clone() { - resolved_for_prompt.set_cost_paid_object_recursive(snapshot); - } if let Some(choice) = mana_choice_prompt( - &ability_def.effect, + &resolved_for_prompt.effect, state, pending.source_id, Some(&resolved_for_prompt), @@ -966,6 +1079,17 @@ fn advance_mana_ability_activation( let cost_events: Vec<_> = events[events_before..].to_vec(); super::triggers::process_triggers(state, &cost_events); } + // CR 605.3a: When the prompt is a single shared color choice and the + // cost resolves with no further player input, surface this source's + // identical activatable twins so `GameAction::ChooseManaColor` can + // bulk-activate them with the chosen color in one round-trip + // (the player's 20 Treasures, etc.). + if matches!(choice, ManaChoicePrompt::SingleColor { .. }) + && cost_resolves_without_choice(&ability_def.cost) + { + pending.batch_siblings = + batch_eligible_siblings(state, pending.player, pending.source_id, &ability_def); + } return Ok(WaitingFor::ChooseManaColor { player: pending.player, choice, @@ -1077,16 +1201,18 @@ fn resolve_mana_ability_with_selected_choices( // CR 117.1 + CR 202.3: Build a transient `ResolvedAbility` carrying the // cost-paid object snapshot so production-count resolution sees it // (Food Chain class). - let mut resolved_for_quantity = - super::ability_utils::build_resolved_from_def(ability_def, source_id, player); - if let Some(snapshot) = cost_paid_object { - resolved_for_quantity.set_cost_paid_object_recursive(snapshot); - } + let resolved_for_quantity = resolved_mana_ability_for_current_state( + state, + source_id, + player, + ability_def, + cost_paid_object, + ); // CR 106.6: Thread restrictions, grants, and expiry through the // selected-choices path too — otherwise color-picked or hybrid-paid mana // abilities would still emit unrestricted mana. - let (produced_mana, restrictions, grants, expiry) = match &*ability_def.effect { + let (produced_mana, restrictions, grants, expiry) = match &resolved_for_quantity.effect { Effect::Mana { produced, restrictions, @@ -1143,7 +1269,7 @@ fn resolve_mana_ability_with_selected_choices( // CR 605.3b + CR 605.1a: Resolve the sub-ability chain inline (painlands' // "deals 1 damage to you", Llanowar Wastes-style self-damage, etc.). - resolve_mana_ability_sub_chain(state, source_id, player, ability_def, events); + resolve_mana_ability_sub_chain(state, &resolved_for_quantity, events); Ok(()) } @@ -1155,19 +1281,16 @@ fn resolve_mana_ability_with_selected_choices( /// controller, GainLife, etc.) route through the standard effect handlers. fn resolve_mana_ability_sub_chain( state: &mut GameState, - source_id: ObjectId, - player: PlayerId, - ability_def: &AbilityDefinition, + ability: &ResolvedAbility, events: &mut Vec, ) { - let Some(sub_def) = ability_def.sub_ability.as_deref() else { + let Some(sub) = ability.sub_ability.as_deref() else { return; }; - let resolved = super::ability_utils::build_resolved_from_def(sub_def, source_id, player); // Errors during the sub-chain are non-fatal — mana has already been // added to the pool and the cost has been paid. The damage/life clause // of a painland cannot legitimately fail in a well-formed game state. - let _ = super::effects::resolve_ability_chain(state, &resolved, events, 0); + let _ = super::effects::resolve_ability_chain(state, sub, events, 0); } #[allow(clippy::too_many_arguments)] @@ -1565,6 +1688,69 @@ pub(crate) fn mana_sub_cost_of(cost: &Option) -> Option<&ManaCost> } } +/// CR 605.3a + CR 605.3b: True iff this cost resolves with NO player prompt once +/// the produced color is pre-chosen — i.e. it hits none of the five interactive +/// cost gates that `advance_mana_ability_activation` checks before producing +/// mana (discard, tap-creatures, non-self exile, non-self sacrifice, and the +/// mana sub-cost handled by `find_*`/`mana_sub_cost_of` directly above/below). +/// This is the eligibility gate for bulk activation: only such sources can be +/// batched behind a single shared color decision (CR 605.3b — no stack, resolves +/// immediately). +/// +/// Deny-by-default whitelist — only `Tap`, self-sacrifice (`SelfRef`, the +/// Treasure/Gold cost shape), and `Composite`s built solely from those qualify. +/// Every other cost variant — including any added later — is treated as +/// choice-bearing and excluded, so a new interactive cost can never silently +/// become batchable. Kept beside the gate matchers so the whitelist stays in +/// lockstep if a sixth gate is introduced. +fn cost_resolves_without_choice(cost: &Option) -> bool { + cost.as_ref().is_none_or(cost_component_choice_free) +} + +fn cost_component_choice_free(cost: &AbilityCost) -> bool { + match cost { + AbilityCost::Tap => true, + AbilityCost::Sacrifice { + target: TargetFilter::SelfRef, + count, + } => *count == 1, + AbilityCost::Composite { costs } => costs.iter().all(cost_component_choice_free), + _ => false, + } +} + +/// CR 605.3a: The controller's *other* permanents that could be activated for +/// the same `SingleColor` mana choice — identical ability definition, choice- +/// free cost, and currently activatable (untapped, on the battlefield, not +/// summoning-sick, via the shared `activatable_mana_options` gate). These are +/// the sources `GameAction::ChooseManaColor` may bulk-activate with the chosen +/// color. `exclude` is the just-activated source (already cost-paid, so omitted). +/// Sorted by id for deterministic ordering across the WASM/multiplayer boundary. +fn batch_eligible_siblings( + state: &GameState, + player: PlayerId, + exclude: ObjectId, + ability_def: &AbilityDefinition, +) -> Vec { + let candidates: Vec = state + .objects + .iter() + .filter_map(|(id, obj)| { + (*id != exclude + && obj.controller == player + && obj.zone == Zone::Battlefield + && obj.abilities.iter().any(|ability| ability == ability_def)) + .then_some(*id) + }) + .collect(); + let mut siblings: Vec = candidates + .into_iter() + .filter(|&id| !mana_sources::activatable_mana_options(state, id, player).is_empty()) + .collect(); + siblings.sort_unstable_by_key(|id| id.0); + siblings +} + /// CR 107.4e + CR 601.2h: Enumerate legal per-hybrid-shard color assignments /// for a mana-ability mana sub-cost. Each returned vector aligns 1:1 with /// hybrid shards in `cost` in printed order. A plan is included iff a clone @@ -2104,12 +2290,13 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - AbilityCondition, AbilityCost, AbilityKind, AbilityTag, ActivationRestriction, + AbilityCondition, AbilityCost, AbilityKind, AbilityTag, ActivationRestriction, Comparator, ContinuousModification, ControllerRef, DevotionColors, Duration, Effect, LinkedExileScope, - ManaContribution, ManaProduction, MultiTargetSpec, PlayerScope, QuantityExpr, QuantityRef, - StaticDefinition, TargetFilter, TypeFilter, TypedFilter, + ManaContribution, ManaProduction, MultiTargetSpec, ObjectScope, PlayerScope, QuantityExpr, + QuantityRef, StaticDefinition, TargetFilter, TypeFilter, TypedFilter, }; use crate::types::card_type::CoreType; + use crate::types::counter::CounterType; use crate::types::game_state::{ExileLink, ExileLinkKind}; use crate::types::identifiers::CardId; use crate::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType}; @@ -2131,6 +2318,41 @@ mod tests { .cost(AbilityCost::Tap) } + fn gemstone_caverns_mana_ability() -> AbilityDefinition { + let replacement = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: ManaColor::ALL.to_vec(), + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .condition(AbilityCondition::ConditionInstead { + inner: Box::new(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::CountersOn { + scope: ObjectScope::Source, + counter_type: Some(CounterType::Generic("luck".to_string())), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + }), + }); + + let mut ability = make_mana_ability(ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }); + ability.sub_ability = Some(Box::new(replacement)); + ability + } + use crate::game::test_fixtures::brushland_colored_ability; fn seed_pool_with(state: &mut GameState, player: PlayerId, color: ManaType, count: usize) { @@ -2230,6 +2452,116 @@ mod tests { .any(|e| matches!(e, GameEvent::ManaAdded { .. }))); } + #[test] + fn condition_instead_mana_ability_without_counter_produces_base_mana() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Gemstone Caverns".to_string(), + Zone::Battlefield, + ); + let ability = gemstone_caverns_mana_ability(); + Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities).push(ability.clone()); + + let mut events = Vec::new(); + let waiting = activate_mana_ability( + &mut state, + source, + PlayerId(0), + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .unwrap(); + + assert_eq!( + waiting, + WaitingFor::Priority { + player: PlayerId(0) + } + ); + assert_eq!( + state.players[0].mana_pool.count_color(ManaType::Colorless), + 1 + ); + assert_eq!(state.players[0].mana_pool.total(), 1); + assert!(state.objects.get(&source).unwrap().tapped); + } + + #[test] + fn condition_instead_mana_ability_with_luck_counter_prompts_for_any_color() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Gemstone Caverns".to_string(), + Zone::Battlefield, + ); + let ability = gemstone_caverns_mana_ability(); + let obj = state.objects.get_mut(&source).unwrap(); + obj.counters + .insert(CounterType::Generic("luck".to_string()), 1); + Arc::make_mut(&mut obj.abilities).push(ability.clone()); + + let mut events = Vec::new(); + let waiting = activate_mana_ability( + &mut state, + source, + PlayerId(0), + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .unwrap(); + + let WaitingFor::ChooseManaColor { + player, + choice: ManaChoicePrompt::SingleColor { options }, + context, + } = waiting + else { + panic!("expected ChooseManaColor, got {waiting:?}"); + }; + assert_eq!(player, PlayerId(0)); + assert_eq!( + options, + vec![ + ManaType::White, + ManaType::Blue, + ManaType::Black, + ManaType::Red, + ManaType::Green, + ] + ); + + let pending = expect_mana_ability_context(context); + handle_choose_mana_color( + &mut state, + &pending, + &ManaChoicePrompt::SingleColor { + options: options.clone(), + }, + ManaChoice::SingleColor(ManaType::Blue), + &mut events, + ) + .unwrap(); + + assert_eq!(state.players[0].mana_pool.count_color(ManaType::Blue), 1); + assert_eq!( + state.players[0].mana_pool.count_color(ManaType::Colorless), + 0 + ); + assert_eq!(state.players[0].mana_pool.total(), 1); + assert!(state.objects.get(&source).unwrap().tapped); + } + #[test] fn exhaust_mana_ability_only_once_is_enforced_and_emits_mana_event() { let mut state = GameState::new_two_player(42); @@ -2768,6 +3100,286 @@ mod tests { .any(|e| matches!(e, GameEvent::PermanentTapped { .. }))); } + /// Build a Treasure-style token — `{T}, Sacrifice this: Add one mana of any + /// color` over `colors` — attached as ability index 0. The + /// `Composite { Tap, Sacrifice SelfRef }` cost is choice-free, so two + /// definition-identical copies are batchable twins (CR 605.3a). + fn make_any_color_treasure( + state: &mut GameState, + card: u64, + player: PlayerId, + colors: Vec, + ) -> ObjectId { + let id = create_object( + state, + CardId(card), + player, + "Treasure".to_string(), + Zone::Battlefield, + ); + let def = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: colors, + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Sacrifice { + target: TargetFilter::SelfRef, + count: 1, + }, + ], + }); + Arc::make_mut(&mut state.objects.get_mut(&id).unwrap().abilities).push(def); + id + } + + /// CR 605.3a: One color choice with `count = N` activates the tapped source + /// plus `N - 1` identical, choice-free twins — `N` mana of the chosen color, + /// `N` sources sacrificed, and a per-source tap each twin (the events a + /// sacrifice observer such as Mayhem Devil/Korvold sees). + #[test] + fn batch_activation_taps_multiple_identical_treasures() { + let mut state = GameState::new_two_player(42); + let a = make_any_color_treasure(&mut state, 9001, PlayerId(0), ManaColor::ALL.to_vec()); + let b = make_any_color_treasure(&mut state, 9002, PlayerId(0), ManaColor::ALL.to_vec()); + let c = make_any_color_treasure(&mut state, 9003, PlayerId(0), ManaColor::ALL.to_vec()); + + let result = crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ActivateAbility { + source_id: a, + ability_index: 0, + }, + ) + .expect("Treasure should activate into a color prompt"); + + let WaitingFor::ChooseManaColor { + context: ManaChoiceContext::ManaAbility(pending), + .. + } = &result.waiting_for + else { + panic!("expected ChooseManaColor, got {:?}", result.waiting_for); + }; + assert_eq!( + pending.batch_siblings, + vec![b, c], + "the other two Treasures are batchable twins" + ); + + let result = crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 3, + }, + ) + .expect("bulk color choice should resolve"); + + assert_eq!( + state.players[0].mana_pool.count_color(ManaType::Red), + 3, + "three Treasures each produced one red" + ); + let on_battlefield = [a, b, c] + .iter() + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|o| o.zone == Zone::Battlefield) + }) + .count(); + assert_eq!(on_battlefield, 0, "all three Treasures were sacrificed"); + // CR 106.12 + CR 605.3a: each twin taps independently during the choice + // step (the first source was tapped earlier, before the prompt). + let twin_taps = result + .events + .iter() + .filter(|e| matches!(e, GameEvent::PermanentTapped { .. })) + .count(); + assert_eq!(twin_taps, 2, "two twins tapped during the bulk activation"); + } + + /// CR 605.3a: A count larger than the available sources is rejected before + /// any mana is produced — no partial application. + #[test] + fn batch_activation_rejects_count_above_available() { + let mut state = GameState::new_two_player(42); + let a = make_any_color_treasure(&mut state, 9101, PlayerId(0), ManaColor::ALL.to_vec()); + let b = make_any_color_treasure(&mut state, 9102, PlayerId(0), ManaColor::ALL.to_vec()); + + crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ActivateAbility { + source_id: a, + ability_index: 0, + }, + ) + .expect("activate first Treasure"); + + let rejected = crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 5, + }, + ); + assert!( + rejected.is_err(), + "count 5 with only two sources is illegal" + ); + assert!( + state + .objects + .get(&b) + .is_some_and(|o| o.zone == Zone::Battlefield), + "the sibling is untouched by the rejected batch" + ); + assert_eq!( + state.players[0].mana_pool.total(), + 0, + "no mana is produced when the batch is rejected" + ); + } + + /// CR 605.3b: The default `count = 1` resolves a single source — twins are + /// left untouched (back-compatible single-tap behavior). + #[test] + fn batch_activation_default_count_resolves_single_source() { + let mut state = GameState::new_two_player(42); + let a = make_any_color_treasure(&mut state, 9151, PlayerId(0), ManaColor::ALL.to_vec()); + let b = make_any_color_treasure(&mut state, 9152, PlayerId(0), ManaColor::ALL.to_vec()); + + crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ActivateAbility { + source_id: a, + ability_index: 0, + }, + ) + .expect("activate first Treasure"); + crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 1, + }, + ) + .expect("single color choice resolves"); + + assert_eq!(state.players[0].mana_pool.count_color(ManaType::Red), 1); + assert!( + state + .objects + .get(&b) + .is_some_and(|o| o.zone == Zone::Battlefield), + "the sibling remains untapped on the battlefield" + ); + } + + /// CR 605.3a: Only definition-identical twins batch together — a different + /// any-color source (distinct ability) is excluded. + #[test] + fn batch_groups_only_identical_ability_definitions() { + let mut state = GameState::new_two_player(42); + let a = make_any_color_treasure(&mut state, 9201, PlayerId(0), ManaColor::ALL.to_vec()); + let b = make_any_color_treasure(&mut state, 9202, PlayerId(0), ManaColor::ALL.to_vec()); + // Distinct AbilityDefinition (only W/U) → not a twin of the 5-color pair. + let _other = make_any_color_treasure( + &mut state, + 9203, + PlayerId(0), + vec![ManaColor::White, ManaColor::Blue], + ); + + let result = crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ActivateAbility { + source_id: a, + ability_index: 0, + }, + ) + .expect("activate the 5-color Treasure"); + + let WaitingFor::ChooseManaColor { + context: ManaChoiceContext::ManaAbility(pending), + .. + } = &result.waiting_for + else { + panic!("expected ChooseManaColor, got {:?}", result.waiting_for); + }; + assert_eq!( + pending.batch_siblings, + vec![b], + "only the identical 5-color Treasure is offered as a twin" + ); + } + + /// CR 605.3a: `cost_resolves_without_choice` is the batch eligibility gate — + /// a deny-by-default whitelist of `Tap`, self-sacrifice, and `Composite`s of + /// those. Any choice-bearing or unrecognized cost is excluded. + #[test] + fn cost_resolves_without_choice_whitelist() { + // Treasure: Tap + self-sacrifice → batchable. + assert!(cost_resolves_without_choice(&Some( + AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Sacrifice { + target: TargetFilter::SelfRef, + count: 1, + }, + ], + } + ))); + assert!(cost_resolves_without_choice(&Some(AbilityCost::Tap))); + assert!(cost_resolves_without_choice(&None)); + + // Phyrexian Altar: sacrifice a (non-self) creature → requires a choice. + assert!(!cost_resolves_without_choice(&Some( + AbilityCost::Sacrifice { + target: TargetFilter::Typed(TypedFilter::creature()), + count: 1, + } + ))); + // Self-sacrifice of more than one is not the single-token shape. + assert!(!cost_resolves_without_choice(&Some( + AbilityCost::Sacrifice { + target: TargetFilter::SelfRef, + count: 2, + } + ))); + // Filter-land style mana sub-cost requires a payment choice. + assert!(!cost_resolves_without_choice(&Some( + AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![], + generic: 1, + }, + }, + AbilityCost::Tap, + ], + } + ))); + // Pay-life is non-interactive but conservatively excluded (deny-by-default). + assert!(!cost_resolves_without_choice(&Some(AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 1 }, + }))); + } + #[test] fn resolve_composite_cost_taps_pays_life_and_produces_mana() { let mut state = GameState::new_two_player(42); @@ -3846,6 +4458,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let prompt = ManaChoicePrompt::SingleColor { options: vec![ManaType::Red, ManaType::Green], @@ -3906,6 +4519,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let prompt = ManaChoicePrompt::SingleColor { options: vec![ManaType::Green, ManaType::White], @@ -4132,6 +4746,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let prompt = ManaChoicePrompt::Combination { options: vec![ @@ -4231,6 +4846,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let prompt = ManaChoicePrompt::Combination { options: vec![ @@ -4792,6 +5408,7 @@ mod tests { &mut state, crate::types::actions::GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(ManaType::Green), + count: 1, }, ) .expect("color choice should resolve"); @@ -5127,6 +5744,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let options = vec![vec![ManaType::Blue], vec![ManaType::Black]]; let mut events = Vec::new(); @@ -5409,6 +6027,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let prompt = ManaChoicePrompt::SingleColor { options: vec![ManaType::Green, ManaType::White], @@ -5844,6 +6463,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let result = handle_sacrifice_for_mana_ability( @@ -5961,6 +6581,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let mut events = Vec::new(); let _ = handle_exile_from_battlefield_for_mana_ability( @@ -6020,6 +6641,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let mut events = Vec::new(); let _ = handle_exile_from_battlefield_for_mana_ability( @@ -6170,6 +6792,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }; let mut events = Vec::new(); let _ = handle_exile_from_battlefield_for_mana_ability( diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 2ae10ba131..cb6c07112f 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -1954,20 +1954,28 @@ pub(super) fn match_always( true } -/// Explored: fires when a creature explores. +/// CR 701.44b: Explored — fires when a creature explores. +/// When `valid_card` is set (e.g. "whenever a creature you control explores"), +/// the filter is checked against the event's source_id (the exploring creature). pub(super) fn match_explored( event: &GameEvent, - _trigger: &TriggerDefinition, - _source_id: ObjectId, - _state: &GameState, + trigger: &TriggerDefinition, + source_id: ObjectId, + state: &GameState, ) -> bool { - matches!( - event, - GameEvent::EffectResolved { - kind: EffectKind::Explore, - .. + if let GameEvent::EffectResolved { + kind: EffectKind::Explore, + source_id: explorer_id, + } = event + { + if trigger.valid_card.is_some() { + valid_card_matches(trigger, state, *explorer_id, source_id) + } else { + true } - ) + } else { + false + } } /// CR 702.110a: "When this creature exploits" = source is the exploiter. @@ -2276,7 +2284,13 @@ pub(super) fn match_rolled_die( source_id: ObjectId, state: &GameState, ) -> bool { - if let GameEvent::DieRolled { player_id, .. } = event { + if let GameEvent::DieRolled { + player_id, sides, .. + } = event + { + if trigger.die_sides.is_some_and(|required| required != *sides) { + return false; + } valid_player_matches(trigger, state, *player_id, source_id) } else { false @@ -2852,6 +2866,52 @@ mod tests { TriggerDefinition::new(mode) } + #[test] + fn rolled_die_matcher_filters_player_and_sides() { + let mut state = setup(); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Pixie Guide".to_string(), + Zone::Battlefield, + ); + let mut trigger = + make_trigger(TriggerMode::RolledDieOnce).valid_target(TargetFilter::Controller); + trigger.die_sides = Some(20); + + assert!(match_rolled_die( + &GameEvent::DieRolled { + player_id: PlayerId(0), + sides: 20, + result: 13, + }, + &trigger, + source, + &state, + )); + assert!(!match_rolled_die( + &GameEvent::DieRolled { + player_id: PlayerId(0), + sides: 6, + result: 4, + }, + &trigger, + source, + &state, + )); + assert!(!match_rolled_die( + &GameEvent::DieRolled { + player_id: PlayerId(1), + sides: 20, + result: 13, + }, + &trigger, + source, + &state, + )); + } + #[test] fn attached_trigger_matches_equipped_source_and_host_filter() { let mut state = setup(); @@ -3135,6 +3195,77 @@ mod tests { )); } + #[test] + fn city_of_traitors_another_land_excludes_source_land() { + let mut state = setup(); + let city = create_object( + &mut state, + CardId(10), + PlayerId(0), + "City of Traitors".to_string(), + Zone::Battlefield, + ); + let other_land = create_object( + &mut state, + CardId(11), + PlayerId(0), + "Ancient Tomb".to_string(), + Zone::Battlefield, + ); + let opponent_land = create_object( + &mut state, + CardId(12), + PlayerId(1), + "Opponent Land".to_string(), + Zone::Battlefield, + ); + for land in [city, other_land, opponent_land] { + state + .objects + .get_mut(&land) + .unwrap() + .card_types + .core_types + .push(CoreType::Land); + } + + let trigger = parse_trigger_line( + "When you play another land, sacrifice this land.", + "City of Traitors", + ); + + assert!(!match_land_played( + &GameEvent::LandPlayed { + object_id: city, + player_id: PlayerId(0), + from_zone: Zone::Hand, + }, + &trigger, + city, + &state, + )); + assert!(match_land_played( + &GameEvent::LandPlayed { + object_id: other_land, + player_id: PlayerId(0), + from_zone: Zone::Hand, + }, + &trigger, + city, + &state, + )); + assert!(!match_land_played( + &GameEvent::LandPlayed { + object_id: opponent_land, + player_id: PlayerId(1), + from_zone: Zone::Hand, + }, + &trigger, + city, + &state, + )); + } + #[test] fn becomes_plotted_matches_only_source_card() { let mut state = setup(); @@ -6981,6 +7112,61 @@ mod tests { assert!(!match_sacrificed(&event, &trigger, source_id, &state)); } + #[test] + fn explored_trigger_filters_exploring_creature_controller() { + let mut state = setup(); + let source_id = create_object( + &mut state, + CardId(340), + PlayerId(0), + "Wildgrowth Walker".to_string(), + Zone::Battlefield, + ); + make_creature(&mut state, source_id); + let controlled_explorer = create_object( + &mut state, + CardId(341), + PlayerId(0), + "Merfolk Branchwalker".to_string(), + Zone::Battlefield, + ); + make_creature(&mut state, controlled_explorer); + let opponent_explorer = create_object( + &mut state, + CardId(342), + PlayerId(1), + "Opponent Scout".to_string(), + Zone::Battlefield, + ); + make_creature(&mut state, opponent_explorer); + let trigger = parse_trigger_line( + "Whenever a creature you control explores, put a +1/+1 counter on this creature and you gain 3 life.", + "Wildgrowth Walker", + ); + + let controlled_event = GameEvent::EffectResolved { + kind: EffectKind::Explore, + source_id: controlled_explorer, + }; + assert!(match_explored( + &controlled_event, + &trigger, + source_id, + &state + )); + + let opponent_event = GameEvent::EffectResolved { + kind: EffectKind::Explore, + source_id: opponent_explorer, + }; + assert!(!match_explored( + &opponent_event, + &trigger, + source_id, + &state + )); + } + #[test] fn sacrifice_blood_token_trigger_honors_token_property() { // CR 111.1 + CR 603.2 + CR 701.21: "Whenever you sacrifice a Blood token" diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 90b5bf66be..3bb5064408 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -3979,7 +3979,7 @@ mod tests { FilterProp, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, ObjectScope, ParsedCondition, PlayerFilter, PlayerScope, PreventionAmount, PtValue, QuantityExpr, QuantityRef, ReplacementCondition, RoundingMode, SharedQuality, SharedQualityRelation, - ShieldKind, StaticCondition, TargetFilter, TypeFilter, TypedFilter, + ShieldKind, StaticCondition, TargetFilter, TriggerCondition, TypeFilter, TypedFilter, }; use crate::types::keywords::{FlashbackCost, KeywordKind, WardCost}; use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; @@ -11203,6 +11203,51 @@ mod tests { ); } + #[test] + fn ability_word_trigger_preserves_fixed_land_subtype_intervening_if() { + let result = parse( + "The Minstrel's Ballad — At the beginning of combat on your turn, if you control five or more Towns, create a 2/2 Elemental creature token that's all colors.", + "The Wandering Minstrel", + &[], + &["Creature"], + &[], + ); + assert_eq!(result.triggers.len(), 1, "triggers={:?}", result.triggers); + let trigger = &result.triggers[0]; + assert_eq!(trigger.mode, TriggerMode::Phase); + assert_eq!(trigger.phase, Some(Phase::BeginCombat)); + assert_eq!( + trigger.constraint, + Some(crate::types::ability::TriggerConstraint::OnlyDuringYourTurn) + ); + match trigger.condition.as_ref() { + Some(TriggerCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(typed), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + }) => { + assert!( + typed + .type_filters + .contains(&TypeFilter::Subtype("Town".to_string())), + "expected Town subtype filter, got {:?}", + typed.type_filters + ); + assert_eq!(typed.controller, Some(ControllerRef::You)); + assert!(typed.properties.contains(&FilterProp::InZone { + zone: Zone::Battlefield + })); + } + other => panic!("expected Town ObjectCount trigger condition, got {other:?}"), + } + } + #[test] fn b20_platinum_angel_both_statics() { // B20: Compound "can't win/lose" line must emit BOTH statics diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 9aa46f419c..0c429bfc58 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -9,6 +9,7 @@ use nom::sequence::{preceded, terminated}; use nom::Parser; use super::super::oracle_nom::bridge::{nom_on_lower, nom_parse_lower}; +use super::super::oracle_nom::condition::inject_controller_you; use super::super::oracle_nom::primitives as nom_primitives; use super::super::oracle_nom::quantity as nom_quantity; use super::super::oracle_quantity::{canonicalize_quantity_ref, parse_cda_quantity}; @@ -1355,6 +1356,10 @@ pub(super) fn parse_condition_text(text: &str) -> Option { return Some(condition); } + if let Some(condition) = parse_controller_controlled_as_cast_condition_text(text) { + return Some(condition); + } + if let Some(condition) = parse_cast_during_phase_condition_text(text) { return Some(condition); } @@ -1431,6 +1436,33 @@ fn parse_urza_land_type(input: &str) -> super::super::oracle_nom::error::OracleR .parse(input) } +fn parse_controller_controlled_as_cast_condition_text(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + nom_parse_lower(&lower, |input| { + all_consuming(parse_controller_controlled_as_cast_condition).parse(input) + }) +} + +fn parse_controller_controlled_as_cast_condition( + input: &str, +) -> OracleResult<'_, AbilityCondition> { + let (rest, _) = tag("you controlled ").parse(input)?; + let (filter, remainder) = parse_type_phrase(rest); + if matches!(filter, TargetFilter::Any) { + return Err(nom::Err::Error(OracleError::new( + input, + nom::error::ErrorKind::Fail, + ))); + } + let (rest, _) = tag("as you cast this spell").parse(remainder.trim_start())?; + Ok(( + rest, + AbilityCondition::ControllerControlledMatchingAsCast { + filter: inject_controller_you(filter), + }, + )) +} + fn parse_cast_during_phase_condition_text(text: &str) -> Option { let lower = text.to_ascii_lowercase(); nom_parse_lower(&lower, parse_cast_during_phase_condition) diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index c8239490fe..71084105e0 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -41,7 +41,7 @@ use super::super::oracle_target::{ }; use super::super::oracle_util::{ contains_possessive, contains_self_or_object_pronoun, parse_count_expr, parse_mana_symbols, - parse_ordinal, split_around, starts_with_possessive, strip_after, TextPair, + parse_ordinal, split_around, starts_with_possessive, TextPair, }; /// CR 702.26: Phasing direction used by the "phase in"/"phase out" dispatch. @@ -261,6 +261,53 @@ fn parse_dynamic_count_phrase(lower: &str) -> Option { None } +fn parse_life_verb_remainder<'a>( + text: &'a str, + lower: &str, + verb: &str, + third_person: &str, +) -> Option<&'a str> { + let you_verb = format!("you {verb} "); + let bare_verb = format!("{verb} "); + let direct_third_person = format!("{third_person} "); + if let Some((_, rest)) = nom_on_lower(text, lower, |input| { + value( + (), + alt(( + tag(you_verb.as_str()), + tag(bare_verb.as_str()), + tag(direct_third_person.as_str()), + )), + ) + .parse(input) + }) { + return Some(rest); + } + + let subject_predicate = format!(" {third_person} "); + nom_on_lower(text, lower, |input| { + let (rest, _) = ( + take_until::<_, _, OracleError<'_>>(subject_predicate.as_str()), + tag(subject_predicate.as_str()), + ) + .parse(input)?; + Ok((rest, ())) + }) + .map(|(_, rest)| rest) +} + +fn parse_life_equal_quantity(after_verb_lower: &str) -> Option { + let (qty_text, _) = tag::<_, _, OracleError<'_>>("life equal to ") + .parse(after_verb_lower) + .ok()?; + let qty_text = qty_text.trim_end_matches('.').trim(); + if let Some(qty) = crate::parser::oracle_quantity::parse_event_context_quantity(qty_text) { + return Some(qty); + } + crate::parser::oracle_quantity::parse_quantity_ref(qty_text) + .map(|qty| QuantityExpr::Ref { qty }) +} + pub(super) fn parse_numeric_imperative_ast( text: &str, lower: &str, @@ -292,7 +339,7 @@ pub(super) fn parse_numeric_imperative_ast( if let Some(count) = parse_dynamic_count_phrase(rest_lower.as_str()) { return Some(NumericImperativeAst::Draw { count, up_to }); } - // CR 119.1 / CR 121.1: When the verb committed but the quantity phrase + // CR 121.1: When the verb committed but the quantity phrase // can't be classified, return None so the line surfaces as // `Effect::Unimplemented` upstream. Silently substituting Fixed{1} hides // dynamic-quantity gaps from the coverage report. @@ -300,144 +347,96 @@ pub(super) fn parse_numeric_imperative_ast( return Some(NumericImperativeAst::Draw { count, up_to }); } - if nom_primitives::scan_contains(lower, "gain") && nom_primitives::scan_contains(lower, "life") - { - // CR 119.1: Handle "life equal to {quantity}" — dynamic amount from game state. - if let Some(qty_text) = - strip_after(lower, "life equal to ").map(|s| s.trim_end_matches('.')) + if let Some(after_gain) = parse_life_verb_remainder(text, lower, "gain", "gains") { + let after_lower = after_gain.to_ascii_lowercase(); + // CR 119.3: Handle "life equal to {quantity}" — dynamic amount from game state. + // CR 119.3: target-relative quantity refs ("target creature's + // power/toughness/mana value"). Mirrors LoseLife. Soul's Grace, + // Heron's Grace Champion, Lifeblood Hydra, etc. + if let Some(amount) = parse_life_equal_quantity(after_lower.as_str()) { + return Some(NumericImperativeAst::GainLife { amount }); + } + // CR 119.3: "gain that much life" / "gain that many life" — + // amount is the triggering event's amount (Exquisite Blood). Extract the + // amount phrase before " life" and route through the event-context + // quantity parser so "that much" resolves to `EventContextAmount` + // rather than defaulting to 1. + // CR 614.1a: First try the full phrase including any " life plus N" / + // " life minus N" suffix. The Offset-aware combinator in + // `parse_event_context_quantity` recognises the post-quantifier noun + // ("that much life plus 1" / "that many life minus 2"), so cards + // like Heron of Hope, Angel of Vitality, Leyline of Hope must be + // probed BEFORE the bare-quantifier path strips " life" via + // `take_until` (which would discard the offset clause). + // + // Strip the trailing " instead" rider via PATTERNS.md §2a: + // `terminated(take_until(...), opt(tag(...)))` — the parser stops at + // the suffix and consumes it (when present), leaving the body for + // the offset combinator. Falls back to the trimmed full phrase + // when the suffix is absent (Exquisite Blood-class non-replacement + // gain-life). + let full_phrase = after_lower.trim_end_matches('.').trim(); + let full_phrase_no_instead = terminated( + take_until::<_, _, OracleError<'_>>(" instead"), + opt(tag(" instead")), + ) + .parse(full_phrase) + .map(|(_rem, body)| body.trim_end()) + .unwrap_or(full_phrase); + if let Some(qty) = + crate::parser::oracle_quantity::parse_event_context_quantity(full_phrase_no_instead) { - if let Some(qty) = - crate::parser::oracle_quantity::parse_event_context_quantity(qty_text) - { - return Some(NumericImperativeAst::GainLife { amount: qty }); - } - // CR 119.1: target-relative quantity refs ("target creature's - // power/toughness/mana value"). Mirrors LoseLife. Soul's Grace, - // Heron's Grace Champion, Lifeblood Hydra, etc. - if let Some(qty) = crate::parser::oracle_quantity::parse_quantity_ref(qty_text) { - return Some(NumericImperativeAst::GainLife { - amount: QuantityExpr::Ref { qty }, - }); - } + return Some(NumericImperativeAst::GainLife { amount: qty }); } - let after_gain = nom_on_lower(text, lower, |input| { - value((), alt((tag("you gain "), tag("gain ")))).parse(input) - }) - .map(|(_, rest)| rest) - .or_else(|| { - nom_on_lower(text, lower, |input| { - let (rest, _) = ( - take_until::<_, _, OracleError<'_>>(" gains "), - tag(" gains "), - ) - .parse(input)?; - Ok((rest, ())) - }) - .map(|(_, rest)| rest) - }) - .unwrap_or(""); - if !after_gain.is_empty() { - // CR 603.7c + CR 119.1: "gain that much life" / "gain that many life" — - // amount is the triggering event's amount (Exquisite Blood). Extract the - // amount phrase before " life" and route through the event-context - // quantity parser so "that much" resolves to `EventContextAmount` - // rather than defaulting to 1. - let after_lower = after_gain.to_ascii_lowercase(); - // CR 614.1a: First try the full phrase including any " life plus N" / - // " life minus N" suffix. The Offset-aware combinator in - // `parse_event_context_quantity` recognises the post-quantifier noun - // ("that much life plus 1" / "that many life minus 2"), so cards - // like Heron of Hope, Angel of Vitality, Leyline of Hope must be - // probed BEFORE the bare-quantifier path strips " life" via - // `take_until` (which would discard the offset clause). - // - // Strip the trailing " instead" rider via PATTERNS.md §2a: - // `terminated(take_until(...), opt(tag(...)))` — the parser stops at - // the suffix and consumes it (when present), leaving the body for - // the offset combinator. Falls back to the trimmed full phrase - // when the suffix is absent (Exquisite Blood-class non-replacement - // gain-life). - let full_phrase = after_lower.trim_end_matches('.').trim(); - let full_phrase_no_instead = terminated( - take_until::<_, _, OracleError<'_>>(" instead"), - opt(tag(" instead")), - ) - .parse(full_phrase) - .map(|(_rem, body)| body.trim_end()) + let amount_phrase = take_until::<_, _, OracleError<'_>>(" life") + .parse(after_lower.as_str()) + .map(|(_, before)| before.trim()) .unwrap_or(full_phrase); - if let Some(qty) = - crate::parser::oracle_quantity::parse_event_context_quantity(full_phrase_no_instead) - { - return Some(NumericImperativeAst::GainLife { amount: qty }); - } - let amount_phrase = take_until::<_, _, OracleError<'_>>(" life") - .parse(after_lower.as_str()) - .map(|(_, before)| before.trim()) - .unwrap_or(full_phrase); - if let Some(qty) = - crate::parser::oracle_quantity::parse_event_context_quantity(amount_phrase) - { - return Some(NumericImperativeAst::GainLife { amount: qty }); - } - // CR 119.1: GainLife committed but quantity phrase unclassified — - // surface as Unimplemented rather than fabricating Fixed{1}. - let amount = parse_count_expr(after_gain).map(|(q, _)| q)?; - return Some(NumericImperativeAst::GainLife { amount }); + if let Some(qty) = + crate::parser::oracle_quantity::parse_event_context_quantity(amount_phrase) + { + return Some(NumericImperativeAst::GainLife { amount: qty }); } + // CR 119.3: GainLife committed but quantity phrase unclassified — + // surface as Unimplemented rather than fabricating Fixed{1}. + let amount = parse_count_expr(after_gain).map(|(q, _)| q)?; + return Some(NumericImperativeAst::GainLife { amount }); } - if nom_primitives::scan_contains(lower, "lose") && nom_primitives::scan_contains(lower, "life") - { - if let Some(expr) = try_parse_half_life_amount(lower) { + if let Some(after_lose) = parse_life_verb_remainder(text, lower, "lose", "loses") { + let after_lower = after_lose.to_ascii_lowercase(); + if let Some(expr) = try_parse_half_life_amount(after_lower.as_str()) { return Some(NumericImperativeAst::LoseLife { amount: expr }); } // CR 119.3: Handle "life equal to {quantity}" — dynamic amount from game state. - if let Some(qty_text) = - strip_after(lower, "life equal to ").map(|s| s.trim_end_matches('.')) + // CR 119.3: target-relative quantity refs ("target creature's + // power/toughness/mana value", etc.) — Final Punishment, Tomb + // Blade-class drain, Genesis of the Daleks. Delegates to the + // shared `parse_quantity_ref` building block. + if let Some(amount) = parse_life_equal_quantity(after_lower.as_str()) { + return Some(NumericImperativeAst::LoseLife { amount }); + } + // CR 119.3: "lose that much life" / "lose that many life" — + // amount is the triggering event's amount. Probe for event-context phrases + // before falling back to the numeric last-word extractor. + if let Ok((_, before_life)) = + take_until::<_, _, OracleError<'_>>("life").parse(after_lower.as_str()) { + let amount_phrase = take_until::<_, _, OracleError<'_>>(" life") + .parse(after_lower.as_str()) + .map(|(_, before)| before.trim()) + .unwrap_or_else(|_: nom::Err>| { + after_lower.trim_end_matches('.').trim() + }); if let Some(qty) = - crate::parser::oracle_quantity::parse_event_context_quantity(qty_text) + crate::parser::oracle_quantity::parse_event_context_quantity(amount_phrase) { return Some(NumericImperativeAst::LoseLife { amount: qty }); } - // CR 119.3: target-relative quantity refs ("target creature's - // power/toughness/mana value", etc.) — Final Punishment, Tomb - // Blade-class drain, Genesis of the Daleks. Delegates to the - // shared `parse_quantity_ref` building block. - if let Some(qty) = crate::parser::oracle_quantity::parse_quantity_ref(qty_text) { - return Some(NumericImperativeAst::LoseLife { - amount: QuantityExpr::Ref { qty }, - }); - } - } - // CR 603.7c + CR 119.3: "lose that much life" / "lose that many life" — - // amount is the triggering event's amount. Probe for event-context phrases - // before falling back to the numeric last-word extractor. - if let Ok((_, before_life)) = take_until::<_, _, OracleError<'_>>("life").parse(lower) { - let after_verb = nom_on_lower(text, lower, |input| { - value((), alt((tag("you lose "), tag("lose ")))).parse(input) - }) - .map(|(_, rest)| rest) - .unwrap_or(""); - if !after_verb.is_empty() { - let after_lower = after_verb.to_ascii_lowercase(); - let amount_phrase = take_until::<_, _, OracleError<'_>>(" life") - .parse(after_lower.as_str()) - .map(|(_, before)| before.trim()) - .unwrap_or_else(|_: nom::Err>| { - after_lower.trim_end_matches('.').trim() - }); - if let Some(qty) = - crate::parser::oracle_quantity::parse_event_context_quantity(amount_phrase) - { - return Some(NumericImperativeAst::LoseLife { amount: qty }); - } - if let Some((amount, remainder)) = parse_count_expr(amount_phrase) { - if remainder.trim().is_empty() { - return Some(NumericImperativeAst::LoseLife { amount }); - } + if let Some((amount, remainder)) = parse_count_expr(amount_phrase) { + if remainder.trim().is_empty() { + return Some(NumericImperativeAst::LoseLife { amount }); } - return None; } // CR 119.3: LoseLife committed but neither the event-context phrase // nor a numeric tail parsed — return None so the line lands in @@ -519,14 +518,10 @@ pub(super) fn parse_numeric_imperative_ast( /// and (b) silently mis-bound the nom remainder. Both bugs disappear by /// routing through the shared combinator. fn try_parse_half_life_amount(lower: &str) -> Option { - // Strip "lose " / "loses " and any intervening whitespace. - let (after_verb, _) = alt((tag::<_, _, OracleError<'_>>("lose "), tag("loses "))) - .parse(lower) - .ok()?; - let after_verb = after_verb.trim_start(); // Delegate to the shared "half ..." combinator. This picks up the // possessive inner ref AND the rounding suffix in one call. - let (_, expr) = super::super::oracle_nom::quantity::parse_half_rounded(after_verb).ok()?; + let (_, expr) = + super::super::oracle_nom::quantity::parse_half_rounded(lower.trim_start()).ok()?; Some(expr) } @@ -3293,7 +3288,7 @@ fn try_parse_that_many_counters(lower: &str, ctx: &mut ParseContext) -> Option { + assert!( + matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::LifeGainedThisTurn { + player: PlayerScope::Controller + } + } + ), + "Expected LifeGainedThisTurn {{ Controller }}, got {amount:?}" + ); + } + other => panic!("Expected LoseLife, got {other:?}"), + } + } + #[test] fn parse_lose_life_two_times_x() { let text = "lose two times X life"; @@ -7605,6 +7627,31 @@ mod tests { } } + #[test] + fn parse_gains_life_equal_to_power() { + let text = "gains life equal to its power"; + let lower = text.to_lowercase(); + let result = parse_numeric_imperative_ast(text, &lower); + assert!( + result.is_some(), + "Should parse stripped third-person gain-life predicates" + ); + match result.unwrap() { + NumericImperativeAst::GainLife { amount } => assert!( + matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: crate::types::ability::ObjectScope::Anaphoric + } + } + ), + "Expected Power {{ Anaphoric }}, got {amount:?}" + ), + other => panic!("Expected GainLife, got {other:?}"), + } + } + #[test] fn parse_amass_zombies_2() { let result = try_parse_amass("amass Zombies 2", "amass zombies 2"); diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 40062ef2a0..cb8ff97078 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use crate::parser::oracle_nom::error::OracleError; use nom::branch::alt; -use nom::bytes::complete::{tag, take_until}; +use nom::bytes::complete::{tag, take_till1, take_until}; use nom::character::complete::{multispace0, multispace1}; use nom::combinator::{all_consuming, eof, map, opt, rest, value}; use nom::multi::many1; @@ -15664,25 +15664,32 @@ fn absorb_trailing_rounding_suffix( (amount, rest) } -fn try_parse_pump(lower: &str, text: &str) -> Option { +fn parse_pump_modifier_phrase(input: &str) -> OracleResult<'_, (PtValue, PtValue)> { + let (rest, _) = opt(alt(( + tag::<_, _, OracleError<'_>>("an additional "), + tag("additional "), + ))) + .parse(input)?; + let (rest, token) = + take_till1(|c: char| c.is_whitespace() || c == ',' || c == '.').parse(rest)?; + let (power, toughness) = parse_pt_modifier(token) + .ok_or_else(|| nom::Err::Error(OracleError::new(token, nom::error::ErrorKind::Verify)))?; + Ok((rest, (power, toughness))) +} + +fn try_parse_pump(lower: &str, _text: &str) -> Option { // Match "+N/+M", "+X/+0", "-X/-X", etc. - let tp = TextPair::new(text, lower); - let re_pos = tp.find("gets ").or_else(|| tp.find("get "))?; - let offset = if tag::<_, _, OracleError<'_>>("gets ") - .parse(&lower[re_pos..]) - .is_ok() - { - 5 - } else { - 4 - }; - let (_, after_tp) = tp.split_at(re_pos + offset); - let after = after_tp.original.trim(); - let token_end = after - .find(|c: char| c.is_whitespace() || c == ',' || c == '.') - .unwrap_or(after.len()); - let token = &after[..token_end]; - parse_pt_modifier(token).map(|(power, toughness)| Effect::Pump { + let (_, (power, toughness), _) = nom_primitives::scan_preceded(lower, |input| { + preceded( + alt(( + tag::<_, _, OracleError<'_>>("gets "), + tag::<_, _, OracleError<'_>>("get "), + )), + parse_pump_modifier_phrase, + ) + .parse(input) + })?; + Some(Effect::Pump { power, toughness, target: TargetFilter::Any, @@ -15699,24 +15706,23 @@ fn parse_pump_clause(predicate: &str) -> Option<(PtValue, PtValue, Option>("gets "), + tag::<_, _, OracleError<'_>>("get "), + )) + .parse(input)?; + let (rest, pt) = parse_pump_modifier_phrase(rest)?; + let (rest, _) = multispace0.parse(rest)?; + let (rest, _) = opt(terminated( + alt((tag::<_, _, OracleError<'_>>(","), tag("."))), + multispace0, + )) + .parse(rest)?; + let (rest, _) = eof.parse(rest)?; + Ok::<_, nom::Err>>((rest, pt)) + })(lower.as_str()) + .ok()?; let power = apply_where_x_expression(power, where_x_expression.as_deref()); let toughness = apply_where_x_expression(toughness, where_x_expression.as_deref()); @@ -18847,6 +18853,67 @@ mod tests { ); } + #[test] + fn parse_pump_clause_accepts_additional_modifier() { + let (power, toughness, duration) = + parse_pump_clause("get an additional -3/-3 until end of turn") + .expect("additional pump should parse"); + assert_eq!(power, PtValue::Fixed(-3)); + assert_eq!(toughness, PtValue::Fixed(-3)); + assert_eq!(duration, Some(Duration::UntilEndOfTurn)); + } + + #[test] + fn faerie_fencing_parses_additional_cast_time_pump() { + let def = parse_effect_chain( + "Target creature gets -X/-X until end of turn. That creature gets an additional -3/-3 until end of turn if you controlled a Faerie as you cast this spell.", + AbilityKind::Spell, + ); + match &*def.effect { + Effect::Pump { + power, toughness, .. + } => { + assert_eq!(power, &PtValue::Variable("-X".to_string())); + assert_eq!(toughness, &PtValue::Variable("-X".to_string())); + } + other => panic!("expected primary Pump, got {other:?}"), + } + + let sub = def + .sub_ability + .expect("additional pump should be a sub ability"); + match &*sub.effect { + Effect::Pump { + power, + toughness, + target, + } => { + assert_eq!(power, &PtValue::Fixed(-3)); + assert_eq!(toughness, &PtValue::Fixed(-3)); + assert_eq!(target, &TargetFilter::ParentTarget); + } + other => panic!("expected additional Pump, got {other:?}"), + } + match sub.condition { + Some(AbilityCondition::ControllerControlledMatchingAsCast { filter }) => { + let TargetFilter::Typed(filter) = filter else { + panic!("cast-time control condition should be typed, got {filter:?}"); + }; + assert_eq!(filter.controller, Some(ControllerRef::You)); + assert!( + filter.properties.iter().any(|prop| matches!( + prop, + FilterProp::InZone { + zone: Zone::Battlefield + } + )), + "cast-time control condition should be battlefield-scoped, got {filter:?}" + ); + } + other => panic!("expected cast-time control condition, got {other:?}"), + } + } + #[test] fn try_split_damage_compound_anaphoric() { // Test via parse_effect_chain (the normal entry point) rather than calling @@ -22231,6 +22298,61 @@ mod tests { )); } + #[test] + fn targeted_keyword_choice_grant_uses_target_only_then_choose_one_of() { + let def = parse_effect_chain( + "Target Mouse you control gains your choice of double strike or trample until end of turn", + AbilityKind::Spell, + ); + + let Effect::TargetOnly { + target: TargetFilter::Typed(target), + } = &*def.effect + else { + panic!("expected TargetOnly head, got {:?}", def.effect); + }; + assert_eq!(target.controller, Some(ControllerRef::You)); + assert!(target + .type_filters + .contains(&TypeFilter::Subtype("Mouse".to_string()))); + + let choice = def + .sub_ability + .as_deref() + .expect("targeted keyword choice must chain a choice prompt"); + let Effect::ChooseOneOf { chooser, branches } = &*choice.effect else { + panic!("expected ChooseOneOf sub-ability, got {:?}", choice.effect); + }; + assert_eq!(*chooser, PlayerFilter::Controller); + assert_eq!(branches.len(), 2); + + for (branch, keyword) in [ + (&branches[0], Keyword::DoubleStrike), + (&branches[1], Keyword::Trample), + ] { + let Effect::GenericEffect { + static_abilities, + duration, + target, + } = &*branch.effect + else { + panic!( + "expected keyword GenericEffect branch, got {:?}", + branch.effect + ); + }; + assert_eq!(*duration, Some(Duration::UntilEndOfTurn)); + assert_eq!(*target, None); + assert_eq!( + static_abilities[0].affected, + Some(TargetFilter::ParentTarget) + ); + assert!(static_abilities[0] + .modifications + .contains(&ContinuousModification::AddKeyword { keyword })); + } + } + /// Issue #501 FOLLOW-UP — ROOT CAUSE A building-block test. A "gains /// suspend" continuous keyword grant carries `Duration::Permanent` (CR /// 702.62b + CR 611.2a): the suspend mechanic owns the card's lifetime, so @@ -34656,6 +34778,28 @@ mod tests { )); } + #[test] + fn each_opponent_loses_life_equal_to_life_you_gained_stays_lose_life() { + let def = parse_effect_chain( + "Each opponent loses life equal to the amount of life you gained this turn.", + AbilityKind::Spell, + ); + + assert_eq!(def.player_scope, Some(PlayerFilter::Opponent)); + let Effect::LoseLife { amount, target } = &*def.effect else { + panic!("expected LoseLife, got {:?}", def.effect); + }; + assert_eq!(target, &None); + assert!(matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::LifeGainedThisTurn { + player: PlayerScope::Controller + } + } + )); + } + #[test] fn each_opponent_gets_counter_keeps_followup_unscoped() { let def = parse_effect_chain( diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 362b50f690..41a7240a25 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -10,13 +10,15 @@ use super::{resolve_it_pronoun, ParseContext}; use crate::parser::oracle_ir::ast::*; use crate::types::ability::{ AbilityDefinition, AbilityKind, ContinuousModification, ControllerRef, Duration, Effect, - FilterProp, GainLifePlayer, MultiTargetSpec, PlayerScope, PtValue, QuantityExpr, QuantityRef, - RoundingMode, StaticDefinition, TargetFilter, TypedFilter, + FilterProp, GainLifePlayer, MultiTargetSpec, PlayerFilter, PlayerScope, PtValue, QuantityExpr, + QuantityRef, RoundingMode, StaticDefinition, TargetFilter, TypedFilter, }; use crate::types::game_state::DayNight; +use crate::types::keywords::Keyword; use crate::types::phase::Phase; use crate::types::statics::StaticMode; +use super::super::oracle_keyword::parse_keyword_from_oracle; use super::super::oracle_nom::error::OracleResult; use super::super::oracle_nom::primitives as nom_primitives; use super::super::oracle_nom::target::parse_event_context_ref; @@ -1201,6 +1203,13 @@ fn build_pump_effect( target, }; } + if application.inherits_parent { + return Effect::Pump { + power, + toughness, + target: TargetFilter::ParentTarget, + }; + } if is_single_object_ref(&application.affected) { return Effect::Pump { power, @@ -1281,6 +1290,75 @@ fn try_split_pump_compound( }) } +fn parse_keyword_choice_grant(predicate: &str) -> Option<(Keyword, Keyword, Option)> { + let lower = predicate.to_lowercase(); + let (choice_text, _) = tag::<_, _, OracleError<'_>>("gain your choice of ") + .parse(lower.as_str()) + .ok()?; + let (keyword_text, duration) = super::strip_trailing_duration(choice_text); + let (_, (left, right)) = nom_primitives::split_once_on(keyword_text.trim(), " or ").ok()?; + let first = parse_keyword_from_oracle(left.trim())?; + let second = parse_keyword_from_oracle(right.trim())?; + Some((first, second, duration.or(Some(Duration::UntilEndOfTurn)))) +} + +fn keyword_choice_branch( + keyword: Keyword, + affected: TargetFilter, + target: Option, + duration: Option, +) -> AbilityDefinition { + let description = format!("gain {keyword}"); + let mut branch = AbilityDefinition::new( + AbilityKind::Spell, + Effect::GenericEffect { + static_abilities: vec![StaticDefinition::continuous() + .affected(affected) + .modifications(vec![ContinuousModification::AddKeyword { keyword }]) + .description(description.clone())], + duration: duration.clone(), + target, + }, + ); + branch.duration = duration; + branch.description = Some(description); + branch +} + +fn build_keyword_choice_clause( + application: &SubjectApplication, + predicate: &str, +) -> Option { + let (first, second, duration) = parse_keyword_choice_grant(predicate)?; + let affected = static_affected_for_application(application); + let branches = vec![ + keyword_choice_branch(first, affected.clone(), None, duration.clone()), + keyword_choice_branch(second, affected, None, duration), + ]; + + let choose_effect = Effect::ChooseOneOf { + chooser: PlayerFilter::Controller, + branches, + }; + let (effect, sub_ability) = if let Some(target) = application.target.clone() { + let choose = AbilityDefinition::new(AbilityKind::Spell, choose_effect); + (Effect::TargetOnly { target }, Some(Box::new(choose))) + } else { + (choose_effect, None) + }; + + Some(ParsedEffectClause { + effect, + duration: None, + sub_ability, + distribute: None, + multi_target: application.multi_target.clone(), + condition: None, + optional: false, + unless_pay: None, + }) +} + fn build_continuous_clause( application: SubjectApplication, predicate: &str, @@ -1325,6 +1403,10 @@ fn build_continuous_clause( return Some(clause); } + if let Some(clause) = build_keyword_choice_clause(&application, &normalized) { + return Some(clause); + } + // Strip "where X is..." and "for each..." suffixes before extracting duration, // so "until end of turn" is found even when followed by these clauses. // The full normalized text is still passed to parse_continuous_modifications diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap index 23fc35aa81..84b1b8da82 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap @@ -39,7 +39,7 @@ expression: "&ir" "sub_ability": { "kind": "Spell", "effect": { - "type": "PumpAll", + "type": "Pump", "power": { "type": "Fixed", "value": 4 @@ -49,12 +49,7 @@ expression: "&ir" "value": 4 }, "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + "type": "ParentTarget" } }, "cost": null, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_lowered.snap index 4751c77996..a2e73095ff 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_lowered.snap @@ -38,7 +38,7 @@ expression: "&lowered" "sub_ability": { "kind": "Spell", "effect": { - "type": "PumpAll", + "type": "Pump", "power": { "type": "Fixed", "value": 4 @@ -48,12 +48,7 @@ expression: "&lowered" "value": 4 }, "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + "type": "ParentTarget" } }, "cost": null, diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 688c1e8111..788cd74283 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -2127,7 +2127,7 @@ fn parse_filter_have_total_property(input: &str) -> OracleResult<'_, StaticCondi } /// Inject `ControllerRef::You` into a TargetFilter produced by `parse_type_phrase`. -fn inject_controller_you(filter: TargetFilter) -> TargetFilter { +pub(crate) fn inject_controller_you(filter: TargetFilter) -> TargetFilter { match filter { TargetFilter::Typed(mut tf) => { tf.controller = Some(ControllerRef::You); @@ -5545,6 +5545,38 @@ mod tests { )); } + #[test] + fn test_control_count_ge_fixed_land_subtype() { + let (rest, c) = parse_inner_condition("you control five or more towns").unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(typed), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + } => { + assert!( + typed + .type_filters + .contains(&TypeFilter::Subtype("Town".to_string())), + "expected Town subtype filter, got {:?}", + typed.type_filters + ); + assert_eq!(typed.controller, Some(ControllerRef::You)); + assert!(typed.properties.contains(&FilterProp::InZone { + zone: Zone::Battlefield + })); + } + other => panic!("expected ObjectCount Town GE 5, got {other:?}"), + } + } + #[test] fn test_graveyard_count_ge() { let (rest, c) = diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index de67445843..56f08deb58 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -100,7 +100,27 @@ fn with_owner_scope(filter: TargetFilter, controller: ControllerRef) -> TargetFi } } -fn self_recursion_trigger_zone(ability: &crate::types::ability::AbilityDefinition) -> Option { +fn parse_self_return_origin_zone(lower: &str) -> Option { + nom_primitives::scan_preceded(lower, |input| { + let (rest, _) = ( + alt(( + tag::<_, _, OracleError<'_>>("return this card "), + tag("return ~ "), + tag("return it "), + )), + tag("from "), + ) + .parse(input)?; + let (rest, zone) = parse_cast_origin_zone(rest)?; + Ok((rest, zone)) + }) + .and_then(|(_, zone, _)| zone) +} + +fn self_recursion_trigger_zone( + ability: &crate::types::ability::AbilityDefinition, + source_lower: &str, +) -> Option { match ability.effect.as_ref() { crate::types::ability::Effect::ChangeZone { origin: Some(origin), @@ -110,16 +130,18 @@ fn self_recursion_trigger_zone(ability: &crate::types::ability::AbilityDefinitio crate::types::ability::Effect::Bounce { target: TargetFilter::SelfRef, destination, - } if destination.is_none_or(|zone| zone == Zone::Hand) => Some(Zone::Graveyard), + } if destination.is_none_or(|zone| zone == Zone::Hand) => { + parse_self_return_origin_zone(source_lower) + } _ => ability .sub_ability .as_deref() - .and_then(self_recursion_trigger_zone) + .and_then(|ability| self_recursion_trigger_zone(ability, source_lower)) .or_else(|| { ability .else_ability .as_deref() - .and_then(self_recursion_trigger_zone) + .and_then(|ability| self_recursion_trigger_zone(ability, source_lower)) }), } } @@ -754,7 +776,11 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { && def.destination == Some(Zone::Graveyard) { def.trigger_zones = vec![Zone::Graveyard]; - } else if let Some(zone) = def.execute.as_deref().and_then(self_recursion_trigger_zone) { + } else if let Some(zone) = def + .execute + .as_deref() + .and_then(|execute| self_recursion_trigger_zone(execute, modifiers.effect_lower.as_str())) + { def.trigger_zones = vec![zone]; } @@ -4880,6 +4906,8 @@ fn try_parse_event( Mutates, ExploitsCreature, Exploits, + /// CR 701.44b: A permanent "explores" after the explore process completes. + Explores, Transforms, Stations, SaddlesOrCrews, @@ -4943,6 +4971,9 @@ fn try_parse_event( // CR 702.110b: "exploits a creature" — exploit trigger value(SimpleEvent::ExploitsCreature, tag("exploits a creature")), value(SimpleEvent::Exploits, tag("exploits")), + // CR 701.44b: "explores" / "explore" — explore trigger + value(SimpleEvent::Explores, tag("explores")), + value(SimpleEvent::Explores, tag("explore")), // CR 712.14: "transforms" / "transforms into" value(SimpleEvent::Transforms, tag("transforms")), // CR 702.184a: "stations ~" — actor-side Station trigger. @@ -5023,6 +5054,14 @@ fn try_parse_event( def.mode = TriggerMode::Exploited; def.valid_card = Some(subject.clone()); } + SimpleEvent::Explores => { + if !remaining.trim().is_empty() { + return None; + } + // CR 701.44b: "explores" fires after the explore process completes. + def.mode = TriggerMode::Explored; + def.valid_card = Some(subject.clone()); + } SimpleEvent::Transforms => { def.mode = TriggerMode::Transformed; def.valid_source = Some(subject.clone()); @@ -5196,10 +5235,89 @@ fn try_parse_named_trigger_mode(lower: &str) -> Option<(TriggerMode, TriggerDefi def.mode = TriggerMode::CrankContraption; return Some((TriggerMode::CrankContraption, def)); } + // CR 309.7: "Whenever you complete a dungeon" — fires as that dungeon card + // is removed from the game. + if ( + alt((tag::<_, _, OracleError<'_>>("whenever "), tag("when "))), + tag("you "), + tag("complete "), + tag("a dungeon"), + ) + .parse(lower) + .is_ok() + { + def.mode = TriggerMode::DungeonCompleted; + def.valid_target = Some(TargetFilter::Controller); + return Some((TriggerMode::DungeonCompleted, def)); + } + + if let Some(result) = try_parse_die_roll_trigger(lower) { + return Some(result); + } + // CR 701.54d: "Whenever the Ring tempts you" / "When the Ring tempts you" — + // the Ring temptation event fires once per temptation resolution. + if all_consuming(pair( + alt((tag::<_, _, OracleError<'_>>("whenever "), tag("when "))), + tag("the ring tempts you"), + )) + .parse(lower) + .is_ok() + { + def.mode = TriggerMode::RingTemptsYou; + return Some((TriggerMode::RingTemptsYou, def)); + } None } +fn try_parse_die_roll_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefinition)> { + // CR 706.2: die-roll triggers compose the triggering player axis + // (you/opponent/player) with the die object axis (a die/a d20/one or more dice). + let (rest, _) = alt(( + value((), tag::<_, _, OracleError<'_>>("whenever ")), + value((), tag("when ")), + )) + .parse(lower) + .ok()?; + + let (rest, valid_target) = parse_die_roll_actor(rest).ok()?; + let (rest, (mode, batched, die_sides)) = parse_die_roll_object(rest).ok()?; + if !rest.is_empty() { + return None; + } + + let mut def = make_base(); + def.mode = mode.clone(); + def.valid_target = Some(valid_target); + def.batched = batched; + def.die_sides = die_sides; + Some((mode, def)) +} + +fn parse_die_roll_actor(input: &str) -> OracleResult<'_, TargetFilter> { + alt(( + value(TargetFilter::Controller, pair(tag("you "), tag("roll "))), + value( + TargetFilter::Typed(TypedFilter::default().controller(ControllerRef::Opponent)), + pair(tag("an opponent "), tag("rolls ")), + ), + value(TargetFilter::Player, pair(tag("a player "), tag("rolls "))), + )) + .parse(input) +} + +fn parse_die_roll_object(input: &str) -> OracleResult<'_, (TriggerMode, bool, Option)> { + alt(( + value( + (TriggerMode::RolledDie, true, None), + tag("one or more dice"), + ), + value((TriggerMode::RolledDieOnce, false, Some(20)), tag("a d20")), + value((TriggerMode::RolledDieOnce, false, None), tag("a die")), + )) + .parse(input) +} + /// CR 120.1 + CR 120.3 + CR 603.2: "Whenever a source [you control] deals /// [combat/noncombat] [N or more] damage [to ], …" — the source-led /// damage-event trigger class. Composes four independent axes (source filter × @@ -6605,11 +6723,11 @@ fn try_parse_player_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefiniti // second-person "play a land" form (you — e.g. Fastbond). The optional // from-zone tail rides through `parse_type_phrase`, matching the existing // cast-spell trigger shape used by Rocco, Street Chef. - if let Some((valid_target, after_land_play)) = parse_land_play_trigger_subject(lower) { + if let Some((valid_target, qualifier, after_land_play)) = parse_land_play_trigger_subject(lower) + { let mut def = make_base(); def.mode = TriggerMode::LandPlayed; def.valid_target = valid_target; - let after_land_play = after_land_play.trim_start(); let clause = terminated( take_until::<_, _, OracleError<'_>>(", "), @@ -6619,10 +6737,12 @@ fn try_parse_player_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefiniti .map(|(_, before)| before) .unwrap_or(after_land_play); let (filter, _) = parse_type_phrase(clause); - if !matches!(filter, TargetFilter::Any) { + if qualifier.is_some() { + // CR 305.1: "another land" excludes the source permanent. + def.valid_card = Some(add_another_prop(filter)); + } else if !matches!(filter, TargetFilter::Any) { def.valid_card = Some(filter); } - return Some((TriggerMode::LandPlayed, def)); } @@ -8753,7 +8873,9 @@ fn parse_turn_constraint(phase_text: &str) -> Option { /// CR 305.1 + CR 603.2: Parse the subject and land-play verb from /// "whenever/when [subject] plays/play a land". -fn parse_land_play_trigger_subject(lower: &str) -> Option<(Option, &str)> { +fn parse_land_play_trigger_subject( + lower: &str, +) -> Option<(Option, Option, &str)> { let (after_prefix, _) = alt(( tag::<_, _, OracleError<'_>>("whenever "), tag::<_, _, OracleError<'_>>("when "), @@ -8776,13 +8898,21 @@ fn parse_land_play_trigger_subject(lower: &str) -> Option<(Option, )) .parse(after_prefix) .ok()?; - let (after_land, _) = alt(( - tag::<_, _, OracleError<'_>>("plays a land"), - tag("play a land"), + // CR 305.1: Decompose verb ("play"/"plays") and land-qualifier ("another"/"a") + // into separate axes to avoid Cartesian-product enumeration. + let (after_verb, _) = alt((tag::<_, _, OracleError<'_>>("plays "), tag("play "))) + .parse(after_subject) + .ok()?; + let (after_land, qualifier) = alt(( + value( + Some(FilterProp::Another), + tag::<_, _, OracleError<'_>>("another land"), + ), + value(None, tag("a land")), )) - .parse(after_subject) + .parse(after_verb) .ok()?; - Some((valid_target, after_land)) + Some((valid_target, qualifier, after_land)) } /// CR 700.13: "Whenever [subject] commits a crime" — scoped crime trigger parser. @@ -9717,6 +9847,36 @@ mod tests { assert_eq!(def.mode, TriggerMode::Exploited); } + #[test] + fn trigger_creature_you_control_explores() { + let def = parse_trigger_line( + "Whenever a creature you control explores, put a +1/+1 counter on Wildgrowth Walker and you gain 3 life.", + "Wildgrowth Walker", + ); + assert_eq!(def.mode, TriggerMode::Explored); + assert!(def.valid_card.is_some()); + } + + #[test] + fn trigger_self_explores() { + let def = parse_trigger_line("Whenever this creature explores, draw a card.", "Test Card"); + assert_eq!(def.mode, TriggerMode::Explored); + assert_eq!(def.valid_card, Some(TargetFilter::SelfRef)); + } + + #[test] + fn trigger_explores_card_quality_remains_unknown() { + let def = parse_trigger_line( + "Whenever a creature you control explores a land card, you may put a land card from your hand onto the battlefield tapped.", + "Nicanzil, Current Conductor", + ); + assert!( + matches!(def.mode, TriggerMode::Unknown(_)), + "explore-card-quality trigger needs event payload support, got {:?}", + def.mode + ); + } + // --- Subject decomposition tests --- #[test] @@ -12656,6 +12816,33 @@ mod tests { )); } + #[test] + fn trigger_city_of_traitors_play_another_land() { + // CR 305.1: "When you play another land, sacrifice ~." + // The "another" qualifier must produce FilterProp::Another on valid_card + // so the trigger does not fire when City of Traitors itself enters. + let t = parse_trigger_line( + "When you play another land, sacrifice this land.", + "City of Traitors", + ); + assert_eq!(t.mode, TriggerMode::LandPlayed); + assert_eq!(t.valid_target, Some(TargetFilter::Controller)); + // valid_card must contain Another + let filter = t + .valid_card + .expect("valid_card must be set for 'another' qualifier"); + match &filter { + TargetFilter::Typed(tf) => { + assert!( + tf.properties.contains(&FilterProp::Another), + "expected FilterProp::Another in properties, got {:?}", + tf.properties + ); + } + other => panic!("expected Typed filter with Another, got {:?}", other), + } + } + #[test] fn extract_if_condition_first_land_pattern() { // CR 305.3 + CR 603.4: Verify the condition is stripped from the effect text. @@ -13974,6 +14161,94 @@ mod tests { assert_eq!(def.mode, TriggerMode::CrankContraption); } + #[test] + fn trigger_dungeon_completed_whenever() { + let def = parse_trigger_line( + "Whenever you complete a dungeon, create a 2/2 green Wolf creature token.", + "Varis, Silverymoon Ranger", + ); + assert_eq!(def.mode, TriggerMode::DungeonCompleted); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + } + #[test] + fn trigger_dungeon_completed_when() { + let def = parse_trigger_line( + "When you complete a dungeon, create a 5/5 red Dragon creature token with flying.", + "Loot Dispute", + ); + assert_eq!(def.mode, TriggerMode::DungeonCompleted); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + } + + #[test] + fn trigger_ring_tempts_you_whenever() { + let def = parse_trigger_line( + "Whenever the Ring tempts you, you may discard your hand.", + "Sauron, the Dark Lord", + ); + assert_eq!(def.mode, TriggerMode::RingTemptsYou); + } + + #[test] + fn trigger_ring_tempts_you_when() { + let def = parse_trigger_line( + "When the Ring tempts you, return this card from your graveyard to your hand.", + "Ringwraiths", + ); + assert_eq!(def.mode, TriggerMode::RingTemptsYou); + } + + #[test] + fn trigger_rolled_die_batch() { + let def = parse_trigger_line( + "Whenever you roll one or more dice, put a +1/+1 counter on ~.", + "Vrondiss, Rage of Ancients", + ); + assert_eq!(def.mode, TriggerMode::RolledDie); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + assert!(def.batched); + assert_eq!(def.die_sides, None); + } + + #[test] + fn trigger_rolled_die_single() { + let def = parse_trigger_line( + "Whenever you roll a die, put a +1/+1 counter on ~.", + "The Space Family Goblinson", + ); + assert_eq!(def.mode, TriggerMode::RolledDieOnce); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + assert!(!def.batched); + assert_eq!(def.die_sides, None); + } + + #[test] + fn trigger_rolled_die_opponent_scope() { + let def = parse_trigger_line( + "Whenever an opponent rolls a die, draw a card.", + "Barbarian Class", + ); + assert_eq!(def.mode, TriggerMode::RolledDieOnce); + assert_eq!( + def.valid_target, + Some(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::Opponent) + )) + ); + assert_eq!(def.die_sides, None); + } + + #[test] + fn trigger_rolled_d20_filters_sides() { + let def = parse_trigger_line( + "Whenever you roll a d20, put a +1/+1 counter on ~.", + "Pixie Guide", + ); + assert_eq!(def.mode, TriggerMode::RolledDieOnce); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + assert_eq!(def.die_sides, Some(20)); + } + #[test] fn trigger_turn_face_up_mode() { let def = parse_trigger_line( @@ -14342,6 +14617,55 @@ mod tests { )); } + #[test] + fn trigger_card_name_self_return_uses_graveyard_zone() { + let def = parse_trigger_line( + "Whenever you fully unlock a Room, you may return Fear of Infinity from your graveyard to your hand.", + "Fear of Infinity", + ); + assert_eq!(def.mode, TriggerMode::FullyUnlock); + assert_eq!(def.trigger_zones, vec![Zone::Graveyard]); + assert!(matches!( + def.execute + .as_deref() + .map(|ability| ability.effect.as_ref()), + Some(Effect::Bounce { + target: TargetFilter::SelfRef, + destination: None, + }) + )); + } + + #[test] + fn phase_trigger_self_bounce_stays_battlefield_hosted() { + let def = parse_trigger_line( + "At the beginning of your upkeep, if you control no Thopters other than this creature, return ~ to its owner's hand and create five 1/1 colorless Thopter artifact creature tokens with flying.", + "Thopter Assembly", + ); + assert_eq!(def.mode, TriggerMode::Phase); + assert_eq!(def.phase, Some(Phase::Upkeep)); + assert!( + def.trigger_zones.is_empty() || def.trigger_zones == vec![Zone::Battlefield], + "ordinary phase trigger must not be hosted from graveyard: {:?}", + def.trigger_zones + ); + let execute = def.execute.as_deref().expect("should have execute"); + assert!(matches!( + execute.effect.as_ref(), + Effect::Bounce { + target: TargetFilter::SelfRef, + destination: None, + } + )); + assert!(matches!( + execute + .sub_ability + .as_deref() + .map(|ability| ability.effect.as_ref()), + Some(Effect::Token { name, .. }) if name == "Thopter" + )); + } + #[test] fn trigger_this_card_becomes_plotted_uses_exile_zone() { let def = parse_trigger_line( diff --git a/crates/engine/src/parser/oracle_util.rs b/crates/engine/src/parser/oracle_util.rs index 1fdedfbdff..cbd2249d21 100644 --- a/crates/engine/src/parser/oracle_util.rs +++ b/crates/engine/src/parser/oracle_util.rs @@ -5,7 +5,9 @@ use super::oracle_nom::error::OracleError; use super::oracle_nom::error::OracleResult; use super::oracle_nom::primitives as nom_primitives; use crate::types::ability::{Comparator, QuantityExpr, QuantityRef, TargetFilter}; -use crate::types::card_type::CoreType; +use crate::types::card_type::{ + fixed_noncreature_subtypes, noncreature_subtype_set, CoreType, SubtypeSet, +}; use crate::types::mana::{ManaColor, ManaCost}; use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; @@ -1237,9 +1239,10 @@ pub(crate) fn is_non_subtype_subject_name(text: &str) -> bool { /// `Cleric Class`, `Druid Arcanist`, `Coward` must not replace the bare /// subtype word in their own Oracle text). pub(crate) fn is_subtype_word(candidate_lower: &str) -> bool { - SUBTYPES - .iter() - .any(|s| s.eq_ignore_ascii_case(candidate_lower)) + fixed_noncreature_subtypes().any(|s| s.eq_ignore_ascii_case(candidate_lower)) + || SUBTYPES + .iter() + .any(|s| s.eq_ignore_ascii_case(candidate_lower)) } /// Test whether a lowercased candidate word matches an MTG supertype. @@ -1281,24 +1284,37 @@ pub fn parse_subtype(text: &str) -> Option<(String, usize)> { } } + for subtype in fixed_noncreature_subtypes() { + if let Some(parsed) = parse_subtype_entry(text, subtype) { + return Some(parsed); + } + } + // Check each subtype (singular and regular plural) for &subtype in SUBTYPES { - // Try singular - if starts_with_word_ci(text, subtype) { - return Some((subtype.to_string(), subtype.len())); + if let Some(parsed) = parse_subtype_entry(text, subtype) { + return Some(parsed); } + } - // Try regular plural: subtype + "s" — check subtype prefix + 's' at boundary - let plural_len = subtype.len() + 1; - if text.len() >= plural_len - && text.is_char_boundary(subtype.len()) - && text[..subtype.len()].eq_ignore_ascii_case(subtype) - && text.as_bytes()[subtype.len()] == b's' - { - let after = &text[plural_len..]; - if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric()) { - return Some((subtype.to_string(), plural_len)); - } + None +} + +fn parse_subtype_entry(text: &str, subtype: &str) -> Option<(String, usize)> { + if starts_with_word_ci(text, subtype) { + return Some((subtype.to_string(), subtype.len())); + } + + // Try regular plural: subtype + "s" — check subtype prefix + 's' at boundary + let plural_len = subtype.len() + 1; + if text.len() >= plural_len + && text.is_char_boundary(subtype.len()) + && text[..subtype.len()].eq_ignore_ascii_case(subtype) + && text.as_bytes()[subtype.len()] == b's' + { + let after = &text[plural_len..]; + if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric()) { + return Some((subtype.to_string(), plural_len)); } } @@ -1315,20 +1331,12 @@ pub fn parse_subtype(text: &str) -> Option<(String, usize)> { /// /// Used by lord-pattern parsers to avoid defaulting all subtypes to Creature. pub fn infer_core_type_for_subtype(subtype: &str) -> Option { - match subtype { - // Artifact subtypes (CR 205.3g) - "Treasure" | "Food" | "Clue" | "Blood" | "Gold" | "Map" | "Junk" | "Powerstone" - | "Equipment" | "Spacecraft" | "Vehicle" | "Fortification" | "Contraption" => { - // CR 205.3g: Spacecraft is an artifact subtype. - Some(CoreType::Artifact) - } - // Land subtypes (CR 205.3i) - "Forest" | "Plains" | "Island" | "Mountain" | "Swamp" | "Desert" | "Gate" | "Locus" - | "Cave" | "Sphere" | "Mine" | "Tower" | "Power-Plant" => Some(CoreType::Land), - // Enchantment subtypes (CR 205.3h) - "Aura" | "Shrine" | "Saga" | "Cartouche" | "Case" | "Class" | "Curse" | "Room" - | "Shard" | "Rune" | "Background" => Some(CoreType::Enchantment), - _ => None, + match noncreature_subtype_set(subtype)? { + SubtypeSet::Land => Some(CoreType::Land), + SubtypeSet::Artifact => Some(CoreType::Artifact), + SubtypeSet::Enchantment => Some(CoreType::Enchantment), + SubtypeSet::Spell | SubtypeSet::Planeswalker | SubtypeSet::Battle => None, + SubtypeSet::Creature => None, } } @@ -2570,9 +2578,16 @@ mod tests { Some(("Spacecraft".to_string(), 11)) ); assert_eq!(parse_subtype("forest"), Some(("Forest".to_string(), 6))); + assert_eq!(parse_subtype("towns"), Some(("Town".to_string(), 5))); assert_eq!(parse_subtype("aura"), Some(("Aura".to_string(), 4))); } + #[test] + fn fixed_noncreature_subtype_helpers_share_authority() { + assert!(is_subtype_word("town")); + assert_eq!(infer_core_type_for_subtype("Town"), Some(CoreType::Land)); + } + #[test] fn parse_subtype_rejects_non_subtypes() { assert_eq!(parse_subtype("creature"), None); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index faab8da390..5b0df7e3d8 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -8719,6 +8719,10 @@ pub enum AbilityCondition { /// Queen on_decline), wrap with `AbilityCondition::Not`. /// `filter` MUST have its `ControllerRef::You` pre-bound by the parser. ControllerControlsMatching { filter: TargetFilter }, + /// CR 601.2 + CR 608.2c: "if you controlled a [filter] as you cast this spell" — + /// gates on a casting-time snapshot in `SpellContext`, not the resolution-time + /// battlefield. The parser pre-binds `ControllerRef::You` and battlefield scope. + ControllerControlledMatchingAsCast { filter: TargetFilter }, /// CR 608.2c: "If it's your turn" — gates sub_ability on whether the active player /// is the ability's controller. For "if it's not your turn", wrap with /// `AbilityCondition::Not`. @@ -8919,6 +8923,12 @@ pub struct SpellContext { /// conditions such as "if you cast this spell during your main phase". #[serde(default, skip_serializing_if = "Option::is_none")] pub cast_phase: Option, + /// CR 601.2 + CR 608.2c: Presence filters the controller matched as the + /// spell was cast. Used by effects that say "if you controlled a [filter] + /// as you cast this spell"; the resolver checks this snapshot instead of + /// the resolution-time battlefield. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub controller_controlled_as_cast: Vec, } impl SpellContext { @@ -9564,6 +9574,10 @@ pub struct TriggerDefinition { /// CR 603.2c: "One or more" triggers fire once per batch of simultaneous events. #[serde(default)] pub batched: bool, + /// CR 706.2: Optional sides filter for die-roll triggers such as + /// "Whenever you roll a d20". `None` accepts any die. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub die_sides: Option, /// CR 700.14: Expend threshold — fires when cumulative mana spent on spells crosses N. #[serde(default, skip_serializing_if = "Option::is_none")] pub expend_threshold: Option, @@ -9607,6 +9621,7 @@ impl TriggerDefinition { counter_filter: None, unless_pay: None, batched: false, + die_sides: None, expend_threshold: None, attack_target_filter: None, player_actions: None, @@ -11573,6 +11588,7 @@ mod tests { counter_filter: None, unless_pay: None, batched: false, + die_sides: None, expend_threshold: None, attack_target_filter: None, player_actions: None, diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index f99e8cd26c..3854376fb0 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -538,8 +538,17 @@ pub enum GameAction { /// Shape mirrors the prompt variant (`SingleColor` or `Combination`). /// `AnyCombination` prompts submit a `Combination` vector with one entry /// per produced mana unit. + /// + /// CR 605.3a: `count` (default 1) bulk-activates `count - 1` additional + /// identical, choice-free mana sources (e.g. a player's other Treasures) + /// with the same color in one round-trip — each is an independent mana + /// ability that resolves before the next (CR 605.3c). Only honored for a + /// `SingleColor` prompt answering a `ManaAbility` context; capped by the + /// engine-computed `PendingManaAbility::batch_siblings`. ChooseManaColor { choice: super::game_state::ManaChoice, + #[serde(default = "default_one")] + count: u32, }, /// CR 605.3a + CR 601.2h + CR 107.4e: Answer the /// `WaitingFor::PayManaAbilityMana` prompt by picking one of the legal @@ -997,6 +1006,12 @@ impl DebugAction { } } +/// Serde default for `GameAction::ChooseManaColor::count` — a single activation +/// when the field is absent (every pre-batch client/serialized action). +fn default_one() -> u32 { + 1 +} + impl GameAction { /// Returns the enum variant name as a static string (e.g., `"CastSpell"`, `"PassPriority"`). /// Useful for structured logging without the full `Debug` representation. diff --git a/crates/engine/src/types/card_type.rs b/crates/engine/src/types/card_type.rs index 98f277acfc..41e3d7e271 100644 --- a/crates/engine/src/types/card_type.rs +++ b/crates/engine/src/types/card_type.rs @@ -182,65 +182,181 @@ pub struct CardType { pub subtypes: Vec, } +pub const LAND_SUBTYPES: &[&str] = &[ + "Cave", + "Desert", + "Forest", + "Gate", + "Island", + "Lair", + "Locus", + "Mine", + "Mountain", + "Plains", + "Planet", + "Power-Plant", + "Sphere", + "Swamp", + "Tower", + "Town", + "Urza's", +]; + +pub const ARTIFACT_SUBTYPES: &[&str] = &[ + "Attraction", + "Blood", + "Bobblehead", + "Book", + "Clue", + "Contraption", + "Equipment", + "Food", + "Fortification", + "Gold", + "Incubator", + "Infinity", + "Junk", + "Lander", + "Map", + "Mutagen", + "Powerstone", + "Spacecraft", + "Stone", + "Treasure", + "Vehicle", +]; + +pub const ENCHANTMENT_SUBTYPES: &[&str] = &[ + "Aura", + "Background", + "Cartouche", + "Case", + "Class", + "Curse", + "Role", + "Room", + "Rune", + "Saga", + "Shard", + "Shrine", +]; + +pub const SPELL_SUBTYPES: &[&str] = &["Adventure", "Arcane", "Lesson", "Omen", "Trap"]; + +pub const BATTLE_SUBTYPES: &[&str] = &["Siege"]; + +pub const PLANESWALKER_SUBTYPES: &[&str] = &[ + "Ajani", + "Aminatou", + "Angrath", + "Arlinn", + "Ashiok", + "Bahamut", + "Basri", + "Bolas", + "Calix", + "Chandra", + "Comet", + "Dack", + "Dakkon", + "Daretti", + "Davriel", + "Dellian", + "Dihada", + "Domri", + "Dovin", + "Ellywick", + "Elminster", + "Elspeth", + "Estrid", + "Freyalise", + "Garruk", + "Gideon", + "Grist", + "Guff", + "Huatli", + "Jace", + "Jared", + "Jaya", + "Jeska", + "Kaito", + "Karn", + "Kasmina", + "Kaya", + "Kiora", + "Koth", + "Liliana", + "Lolth", + "Lukka", + "Minsc", + "Mordenkainen", + "Nahiri", + "Narset", + "Niko", + "Nissa", + "Nixilis", + "Oko", + "Quintorius", + "Ral", + "Rowan", + "Saheeli", + "Samut", + "Sarkhan", + "Serra", + "Sivitri", + "Sorin", + "Szat", + "Tamiyo", + "Tasha", + "Teferi", + "Teyo", + "Tezzeret", + "Tibalt", + "Tyvar", + "Ugin", + "Urza", + "Venser", + "Vivien", + "Vraska", + "Vronos", + "Will", + "Windgrace", + "Wrenn", + "Xenagos", + "Yanggu", + "Yanling", + "Zariel", +]; + +pub fn fixed_noncreature_subtypes() -> impl Iterator { + LAND_SUBTYPES + .iter() + .chain(ARTIFACT_SUBTYPES) + .chain(ENCHANTMENT_SUBTYPES) + .chain(SPELL_SUBTYPES) + .chain(BATTLE_SUBTYPES) + .chain(PLANESWALKER_SUBTYPES) + .copied() +} + /// CR 205.3i: Returns true if the given string is a land subtype. /// Used by `SetBasicLandType` to remove only land subtypes while preserving /// non-land subtypes (e.g., creature subtypes on Land Creatures like Dryad Arbor). pub fn is_land_subtype(s: &str) -> bool { - matches!( - s, - "Cave" - | "Desert" - | "Forest" - | "Gate" - | "Island" - | "Lair" - | "Locus" - | "Mine" - | "Mountain" - | "Plains" - | "Planet" - | "Power-Plant" - | "Sphere" - | "Swamp" - | "Tower" - | "Town" - | "Urza's" - ) + LAND_SUBTYPES.contains(&s) } /// CR 205.3: Return the noncreature subtype set for subtypes whose membership /// is fixed by the card-type rules. Creature subtypes are intentionally /// excluded because the runtime card database owns that list. pub fn noncreature_subtype_set(s: &str) -> Option { - if is_land_subtype(s) { - return Some(SubtypeSet::Land); - } match s { - // CR 205.3g: Artifact subtypes. - "Attraction" | "Blood" | "Bobblehead" | "Book" | "Clue" | "Contraption" | "Equipment" - | "Food" | "Fortification" | "Gold" | "Incubator" | "Infinity" | "Junk" | "Lander" - | "Map" | "Mutagen" | "Powerstone" | "Spacecraft" | "Stone" | "Treasure" | "Vehicle" => { - Some(SubtypeSet::Artifact) - } - // CR 205.3h: Enchantment subtypes. - "Aura" | "Background" | "Cartouche" | "Case" | "Class" | "Curse" | "Role" | "Room" - | "Rune" | "Saga" | "Shard" | "Shrine" => Some(SubtypeSet::Enchantment), - // CR 205.3k: Spell subtypes. - "Adventure" | "Arcane" | "Lesson" | "Omen" | "Trap" => Some(SubtypeSet::Spell), - // CR 205.3q: Battle subtypes. - "Siege" => Some(SubtypeSet::Battle), - // CR 205.3j: Planeswalker subtypes. - "Ajani" | "Aminatou" | "Angrath" | "Arlinn" | "Ashiok" | "Bahamut" | "Basri" | "Bolas" - | "Calix" | "Chandra" | "Comet" | "Dack" | "Dakkon" | "Daretti" | "Davriel" | "Dellian" - | "Dihada" | "Domri" | "Dovin" | "Ellywick" | "Elminster" | "Elspeth" | "Estrid" - | "Freyalise" | "Garruk" | "Gideon" | "Grist" | "Guff" | "Huatli" | "Jace" | "Jared" - | "Jaya" | "Jeska" | "Kaito" | "Karn" | "Kasmina" | "Kaya" | "Kiora" | "Koth" - | "Liliana" | "Lolth" | "Lukka" | "Minsc" | "Mordenkainen" | "Nahiri" | "Narset" - | "Niko" | "Nissa" | "Nixilis" | "Oko" | "Quintorius" | "Ral" | "Rowan" | "Saheeli" - | "Samut" | "Sarkhan" | "Serra" | "Sivitri" | "Sorin" | "Szat" | "Tamiyo" | "Tasha" - | "Teferi" | "Teyo" | "Tezzeret" | "Tibalt" | "Tyvar" | "Ugin" | "Urza" | "Venser" - | "Vivien" | "Vraska" | "Vronos" | "Will" | "Windgrace" | "Wrenn" | "Xenagos" - | "Yanggu" | "Yanling" | "Zariel" => Some(SubtypeSet::Planeswalker), + s if LAND_SUBTYPES.contains(&s) => Some(SubtypeSet::Land), + s if ARTIFACT_SUBTYPES.contains(&s) => Some(SubtypeSet::Artifact), + s if ENCHANTMENT_SUBTYPES.contains(&s) => Some(SubtypeSet::Enchantment), + s if SPELL_SUBTYPES.contains(&s) => Some(SubtypeSet::Spell), + s if BATTLE_SUBTYPES.contains(&s) => Some(SubtypeSet::Battle), + s if PLANESWALKER_SUBTYPES.contains(&s) => Some(SubtypeSet::Planeswalker), _ => None, } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 79346a6a04..db6204c877 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1013,6 +1013,15 @@ pub struct PendingManaAbility { /// resolve in inline mana ability resolution. #[serde(default, skip_serializing_if = "Option::is_none")] pub cost_paid_object: Option, + /// CR 605.3a: Other identical, choice-free mana sources the controller + /// could activate for the same `SingleColor` prompt (their other + /// Treasures, etc.). Computed only when the prompt is `SingleColor` and the + /// cost resolves with no further player choice. `GameAction::ChooseManaColor` + /// may bulk-activate up to this many additional sources with the chosen + /// color. The frontend reads `.len()` to cap its quantity stepper. Empty for + /// every non-batchable activation (the default). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub batch_siblings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -5225,6 +5234,7 @@ mod tests { chosen_exiled_battlefield: Vec::new(), chosen_sacrificed_battlefield: Vec::new(), cost_paid_object: None, + batch_siblings: Vec::new(), }), }; assert!(!tap_mana.has_pending_cast()); diff --git a/crates/engine/tests/integration/brigid_mana_ability.rs b/crates/engine/tests/integration/brigid_mana_ability.rs index 637d1abf73..6e59a7ae2f 100644 --- a/crates/engine/tests/integration/brigid_mana_ability.rs +++ b/crates/engine/tests/integration/brigid_mana_ability.rs @@ -74,6 +74,7 @@ fn brigid_activated_ability_offers_color_choice_and_produces_x_mana() { runner .act(GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(ManaType::Green), + count: 1, }) .expect("submitting the Green color choice must succeed"); @@ -122,6 +123,7 @@ fn brigid_with_no_other_creatures_produces_zero_mana() { runner .act(GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(ManaType::White), + count: 1, }) .expect("submitting the White color choice must succeed"); } diff --git a/crates/mtgish-import/src/diff/ordering.rs b/crates/mtgish-import/src/diff/ordering.rs index 75c912d1ce..d9e58e5cad 100644 --- a/crates/mtgish-import/src/diff/ordering.rs +++ b/crates/mtgish-import/src/diff/ordering.rs @@ -350,6 +350,10 @@ pub const ORDERING_MANIFEST: &[((&str, &str), OrderingClass)] = &[ ("SpellContext", "kickers_paid"), OrderingClass::SetEquivalent, ), + ( + ("SpellContext", "controller_controlled_as_cast"), + OrderingClass::SetEquivalent, + ), // ----- Trigger cause filters ----- (("TriggerCause", "core_types"), OrderingClass::SetEquivalent), ]; diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 29182ebc54..84f8b89ed3 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -707,11 +707,13 @@ fn fallback_action(state: &GameState) -> Option { ManaChoicePrompt::SingleColor { options } => { options.first().map(|&color| GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(color), + count: 1, }) } ManaChoicePrompt::Combination { options } => { options.first().map(|combo| GameAction::ChooseManaColor { choice: ManaChoice::Combination(combo.clone()), + count: 1, }) } ManaChoicePrompt::AnyCombination { count, options } => { @@ -724,6 +726,7 @@ fn fallback_action(state: &GameState) -> Option { ]; Some(GameAction::ChooseManaColor { choice: ManaChoice::Combination(combo), + count: 1, }) } }