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() {
- {debugInteractionMode ? "Click Mode ON" : "Click Mode"}
+ {debugInteractionMode ? "● Click Mode ON" : "Click Mode"}
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.
+
+
+ setDismissed(true)}
+ className="rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-400 transition hover:bg-white/8 hover:text-slate-200"
+ >
+ Dismiss
+
+ {
+ setDismissed(true);
+ openSandboxTools();
+ }}
+ className="rounded-lg bg-amber-400 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-amber-300"
+ >
+ Open Sandbox Tools
+
+
+
+ );
+}
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?
+
+ setCount((c) => Math.max(1, c - 1))}
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-white/20 text-xl leading-none text-white transition hover:border-white/40 disabled:opacity-30"
+ >
+ −
+
+
+ {count}
+
+ = batchMax}
+ onClick={() => setCount((c) => Math.min(batchMax, c + 1))}
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-white/20 text-xl leading-none text-white transition hover:border-white/40 disabled:opacity-30"
+ >
+ +
+
+ / {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,
})
}
}