diff --git a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts index 09741924d1..18bd28f042 100644 --- a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts +++ b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts @@ -59,6 +59,7 @@ describe("mana-source selection wire types", () => { source: { object_id: 17, incarnation: 3 }, ability_index: 0, mana_type: "Blue", + output: { type: "Concrete", data: "Blue" }, atomic_combination: null, restrictions, penalty: "None", diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 0e71b51b46..fff6e5b18f 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -57,7 +57,7 @@ export type SelectionConstraint = { "type": "count", "data": { min: number, max: export type ConfirmSemantics = "immediate" | "explicit"; -export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; +export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; export type InteractionRoleCode = "source" | "candidate" | "partner" | "attackTarget" | "target" | "paymentMode" | "abilityIndex" | "attacker" | "bandCount" | "blocker" | "blocked" | "untap" | "exert" | "enlistTarget" | "enlist" | "opponent" | "assistPlayer" | "assist" | "genericMana" | "mulligan" | "serumPowder" | "handCard" | "selected" | "counterSource" | "counterType" | "amount" | "coinFlipIndex" | "sideboardIndex" | "faceUpExile" | "optionIndex" | "triggerIndex" | "crewMember" | "stationCrew" | "x" | "mainCard" | "sideboardCard" | "playFirst" | "option" | "candidateIndex" | "cardName" | "pileA" | "pile" | "modeIndex" | "pay" | "face" | "castCost" | "permanentType" | "returnCreature" | "permissionSource" | "accept" | "spliceCard" | "splice" | "choice" | "costBranch" | "costBranchIndex" | "pair" | "dungeon" | "roomIndex" | "door" | "operation" | "convokeMana" | "harmonizeCreature" | "harmonize" | "companion" | "castChoice" | "castCard" | "placement" | "mergeSide" | "encodeCreature" | "encode" | "defender" | "protector" | "assignmentMode" | "damageTarget" | "damageAmount" | "trampleDamage" | "controllerDamage" | "destination" | "discardCard" | "learn" | "category" | "kept" | "phyrexianPayment" | "manaChoice" | "count" | "manaPayment" | "producedMana" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate"; diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ec938837a8..ddc7c48e62 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -668,7 +668,10 @@ export interface CastingVariantChoiceOption { mana_cost: ManaCost; } -export type CastPaymentMode = { type: "Auto" } | { type: "Manual" }; +export type CastPaymentMode = + | { type: "Auto" } + | { type: "AutoExceptSacrificialMana" } + | { type: "Manual" }; export type UnlessCost = | { type: "Fixed"; cost: ManaCost } @@ -1158,6 +1161,10 @@ export type ManaSourcePenalty = | { PaysLifeOnActivation: { fixed_amount: number | null } } | "Sacrifices"; +export type ManaSourceOutput = + | { type: "Concrete"; data: ManaType } + | { type: "DeferredColorChoice" }; + export type ProductionOverride = | { type: "SingleColor"; data: ManaType } | { type: "Combination"; data: ManaType[] }; @@ -1172,6 +1179,7 @@ export interface ManaSourceSelection { source: ObjectIncarnationRef; ability_index: number | null; mana_type: ManaType; + output: ManaSourceOutput; atomic_combination: ManaType[] | null; restrictions: ManaRestriction[]; penalty: ManaSourcePenalty; @@ -1659,6 +1667,7 @@ export type WaitingFor = }; } | { type: "ManaPayment"; data: { player: PlayerId; convoke_mode?: ConvokeMode } } + | { type: "ManaSourceSelection"; data: { player: PlayerId; options: ManaSourceSelection[]; convoke_mode?: ConvokeMode } } | { type: "ChooseXValue"; data: { @@ -2186,6 +2195,8 @@ export type GameAction = | { type: "MulliganDecision"; data: { choice: MulliganChoice } } | { type: "ReorderHand"; data: { order: ObjectId[] } } | { type: "TapLandForMana"; data: { selection: ManaSourceSelection } } + | { type: "ActivateManaSource"; data: { selection: ManaSourceSelection } } + | { type: "BackToManaPayment" } | { type: "UntapLandForMana"; data: { object_id: ObjectId } } // CR 118.3a: pin / unpin a specific pool unit during manual mana payment. | { type: "SpendPoolMana"; data: { pip_id: number } } @@ -2927,6 +2938,7 @@ export interface GameState { combat: CombatState | null; waiting_for: WaitingFor; has_pending_cast: boolean; + allows_cancel_cast?: boolean; /** * CR 601.2f: The locked-in pending cast (cost, ability, object) while the * caster is mid-cast. Present during ManaPayment / cost-choice WaitingFor diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index 7c8d3f6038..0da939609d 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -1169,6 +1169,7 @@ describe("PermanentCard", () => { source: { object_id: 39, incarnation: 1 }, ability_index: null, mana_type: "Green", + output: { type: "Concrete", data: "Green" }, atomic_combination: null, restrictions: [], penalty: "None", diff --git a/client/src/components/mana/ManaPaymentUI.tsx b/client/src/components/mana/ManaPaymentUI.tsx index ac9b24a55c..834eb1d0af 100644 --- a/client/src/components/mana/ManaPaymentUI.tsx +++ b/client/src/components/mana/ManaPaymentUI.tsx @@ -621,6 +621,69 @@ export function ManaPaymentUI() { ); } +/** CR 605.3b: The engine has reached a mana ability that sacrifices a + * permanent. It supplies every legal capability; this display layer only + * renders those rows and returns the selected opaque action. */ +export function ManaSourceSelectionUI() { + const { t } = useTranslation("game"); + const waitingFor = useGameStore((s) => s.waitingFor); + const gameState = useGameStore((s) => s.gameState); + const dispatch = useGameStore((s) => s.dispatch); + + if (waitingFor?.type !== "ManaSourceSelection") return null; + + return ( + + + + {t("manaSourceSelection.title")} + {t("manaSourceSelection.description")} + + {waitingFor.data.options.map((selection, index) => { + const source = gameState?.objects[selection.source.object_id]; + return ( + dispatch({ type: "ActivateManaSource", data: { selection } })} + > + {source?.name ?? t("manaSourceSelection.unknownSource")} + {t("manaSourceSelection.sacrifice")} + + ); + })} + + dispatch({ type: "BackToManaPayment" })} + > + {t("manaSourceSelection.back")} + + + + + ); +} + // Color → shard symbol code for `ManaSymbol` (White→"W", …, Colorless→"C"). const COLOR_SHARD: Record = { White: "W", diff --git a/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx b/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx index 2c68afa18e..639357af7c 100644 --- a/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx +++ b/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { ManaPaymentUI } from "../ManaPaymentUI"; +import { ManaPaymentUI, ManaSourceSelectionUI } from "../ManaPaymentUI"; import { useGameStore } from "../../../stores/gameStore"; import type { GameState } from "../../../adapter/types"; import { buildGameObjectWithCoreTypes, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts"; @@ -421,3 +421,63 @@ describe("ManaPaymentUI", () => { }); }); }); + +describe("ManaSourceSelectionUI", () => { + beforeEach(() => { + useGameStore.getState().reset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("dispatches only the engine-issued sacrificial source and Back action", () => { + const dispatch = vi.fn().mockResolvedValue([]); + const source = buildGameObjectWithCoreTypes(["Artifact"], { + id: 91, + card_id: 91, + name: "Basal Sliver", + zone: "Battlefield", + }); + const selection = { + source: { object_id: 91, incarnation: 0 }, + ability_index: 0, + mana_type: "Black" as const, + output: { type: "Concrete" as const, data: "Black" as const }, + atomic_combination: null, + restrictions: [], + penalty: "Sacrifices" as const, + taps_for_mana: [], + }; + const gameState = createGameState({ + objects: buildObjectMap(source), + waiting_for: { + type: "ManaSourceSelection", + data: { + player: 0, + options: [selection], + }, + }, + }); + + act(() => { + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch, + legalActions: [], + }); + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /basal sliver/i })); + expect(dispatch).toHaveBeenCalledWith({ + type: "ActivateManaSource", + data: { selection }, + }); + + fireEvent.click(screen.getByRole("button", { name: /back to mana payment/i })); + expect(dispatch).toHaveBeenCalledWith({ type: "BackToManaPayment" }); + }); +}); diff --git a/client/src/components/settings/PreferencesModal.tsx b/client/src/components/settings/PreferencesModal.tsx index 400e761eef..89c682a38b 100644 --- a/client/src/components/settings/PreferencesModal.tsx +++ b/client/src/components/settings/PreferencesModal.tsx @@ -34,6 +34,7 @@ import type { CommandZoneDisplay, LogDefaultState, MultiplayerBoardLayout, + SpellPaymentMode, ZoneCollapseMode, } from "../../stores/preferencesStore.ts"; import type { SupportedLng } from "../../i18n/resources.ts"; @@ -72,6 +73,7 @@ const CARD_SIZES: CardSizePreference[] = ["small", "medium", "large"]; const COMMAND_ZONE_DISPLAYS: CommandZoneDisplay[] = ["auto", "inline", "compact"]; const ZONE_COLLAPSE_MODES: ZoneCollapseMode[] = ["auto", "on", "off"]; const CARD_PREVIEW_MODES: CardPreviewMode[] = ["follow", "side", "shift"]; +const SPELL_PAYMENT_MODES: SpellPaymentMode[] = ["auto", "autoExceptSacrificialMana", "manual"]; const LOG_DEFAULTS: LogDefaultState[] = ["open", "closed"]; const VFX_QUALITIES: VfxQuality[] = ["full", "reduced", "minimal"]; const MULTIPLAYER_BOARD_LAYOUTS: MultiplayerBoardLayout[] = ["focused", "split"]; @@ -428,15 +430,12 @@ export function PreferencesModal({ - - setSpellPaymentMode(e.target.checked ? "manual" : "auto")} - className="accent-cyan-500" - /> - {t("gameplay.manualManaPayment")} - + t(`gameplay.spellPaymentOptions.${option}`)} + /> {isTauri() && ( @@ -723,7 +722,7 @@ export function PreferencesModal({ type="button" onClick={handleImportTheme} disabled={themeImportStatus === "loading" || !themeImportUrl.trim()} - className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-slate-100 hover:bg-sky-600/50 disabled:opacity-50" + className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-white hover:bg-sky-600/50 disabled:opacity-50" > {themeImportStatus === "loading" ? t("audioTheme.loading") : t("audioTheme.import")} @@ -1537,7 +1536,7 @@ function ArtChainEditor({ type="button" onClick={handleAddSet} disabled={!resolveSetCode(setInput)} - className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-slate-100 hover:bg-sky-600/50 disabled:opacity-50" + className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-white hover:bg-sky-600/50 disabled:opacity-50" > {t("artChain.addSet")} diff --git a/client/src/game/__tests__/castPaymentMode.test.ts b/client/src/game/__tests__/castPaymentMode.test.ts index 7d9c5be5b8..f717b30312 100644 --- a/client/src/game/__tests__/castPaymentMode.test.ts +++ b/client/src/game/__tests__/castPaymentMode.test.ts @@ -38,4 +38,35 @@ describe("applySpellPaymentPreference", () => { type: "Manual", }); }); + + it("stamps every cast-family action with the sacrificial-mana preference", () => { + usePreferencesStore.setState({ spellPaymentMode: "autoExceptSacrificialMana" }); + const castTypes = [ + "CastSpell", + "CastSpellForFree", + "CastSpellAsMiracle", + "CastSpellAsMadness", + "CastSpellAsSneak", + "CastSpellAsWebSlinging", + ] as const; + + for (const type of castTypes) { + const action = { type, data: {} } as GameAction; + const result = applySpellPaymentPreference(action) as GameAction & { + data: { payment_mode?: unknown }; + }; + expect(result.data.payment_mode).toEqual({ type: "AutoExceptSacrificialMana" }); + } + }); + + it("lets the per-game manual override dominate the sacrificial-mana preference", () => { + usePreferencesStore.setState({ spellPaymentMode: "autoExceptSacrificialMana" }); + useUiStore.setState({ manualManaOverride: true }); + + const result = applySpellPaymentPreference(castAction); + + expect((result as Extract).data.payment_mode).toEqual({ + type: "Manual", + }); + }); }); diff --git a/client/src/game/castPaymentMode.ts b/client/src/game/castPaymentMode.ts index 42ffebe37b..a94c7e6836 100644 --- a/client/src/game/castPaymentMode.ts +++ b/client/src/game/castPaymentMode.ts @@ -3,45 +3,52 @@ import { usePreferencesStore } from "../stores/preferencesStore"; import { useUiStore } from "../stores/uiStore"; const MANUAL_CAST_PAYMENT_MODE: CastPaymentMode = { type: "Manual" }; +const AUTO_EXCEPT_SACRIFICIAL_MANA_PAYMENT_MODE: CastPaymentMode = { + type: "AutoExceptSacrificialMana", +}; export function applySpellPaymentPreference(action: GameAction): GameAction { - // Two intended sources of truth: the durable `spellPaymentMode` preference and - // the ephemeral per-game `manualManaOverride` toggle. Manual wins if EITHER is on. - const manual = - usePreferencesStore.getState().spellPaymentMode === "manual" || - useUiStore.getState().manualManaOverride; - if (!manual) return action; + // Manual is the per-game escape hatch and deliberately dominates the saved + // preference. Otherwise stamp every cast-family action with the exact engine + // mode so all alternate casting routes share the same payment semantics. + const preference = usePreferencesStore.getState().spellPaymentMode; + const mode = useUiStore.getState().manualManaOverride || preference === "manual" + ? MANUAL_CAST_PAYMENT_MODE + : preference === "autoExceptSacrificialMana" + ? AUTO_EXCEPT_SACRIFICIAL_MANA_PAYMENT_MODE + : null; + if (!mode) return action; switch (action.type) { case "CastSpell": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellForFree": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsMiracle": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsMadness": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsSneak": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsWebSlinging": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; default: return action; diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index a52f1c9eae..21e0586f62 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -280,7 +280,16 @@ export function createAIController(config: AIControllerConfig): AIController { waitingFor: WaitingFor, state: GameState, ): Promise { - if (state.has_pending_cast) { + if (waitingFor.type === "ManaSourceSelection") { + const { adapter } = useGameStore.getState(); + if (!adapter) return Promise.resolve(null); + return adapter.getLegalActions().then((result) => { + return result.actions.find((action) => action.type === "ActivateManaSource") + ?? result.actions.find((action) => action.type === "BackToManaPayment") + ?? null; + }); + } + if (state.has_pending_cast && state.allows_cancel_cast) { return Promise.resolve({ type: "CancelCast" }); } const { adapter } = useGameStore.getState(); diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index b70f979ca5..e8dfb6b406 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -38,6 +38,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = "MeldAttackTargetChoice", // Cast / activation chain — ManaPayment + PhyrexianPayment share ManaPaymentUI. ...MANA_PAYMENT_WAITING_FOR_TYPES, + "ManaSourceSelection", "ChooseXValue", "PayAmountChoice", "TargetSelection", @@ -247,6 +248,7 @@ export function waitingForReason( case "RetargetChoice": return { key: "status.reason.choosingTargets" }; case "ManaPayment": + case "ManaSourceSelection": case "PhyrexianPayment": case "PayCost": case "PayManaAbilityMana": diff --git a/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx b/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx index dce6c74109..9f3d26bcbc 100644 --- a/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx +++ b/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx @@ -132,6 +132,7 @@ describe("useKeyboardShortcuts", () => { source: { object_id: objectId, incarnation: 1 }, ability_index: null, mana_type: "Green", + output: { type: "Concrete", data: "Green" }, atomic_combination: null, restrictions: [], penalty: "None", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index fb7f3e5988..fdba287aa7 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Kann derzeit nicht aktiviert werden", "prohibited": "Das Aktivieren dieser Fähigkeit ist verboten" }, + "manaSourceSelection": { "title": "Manaquelle wählen", "description": "Diese Manafähigkeit erfordert das Opfern einer bleibenden Karte.", "sacrifice": "Opfern", "back": "Zurück zur Manabezahlung", "unknownSource": "Manaquelle" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/de/settings.json b/client/src/i18n/locales/de/settings.json index 70847ac630..d5699bd6af 100644 --- a/client/src/i18n/locales/de/settings.json +++ b/client/src/i18n/locales/de/settings.json @@ -29,6 +29,7 @@ "logDefault": "Protokoll-Standard", "spellPayment": "Zauberspruch-Bezahlung", "manualManaPayment": "Manuelle Manabezahlung für Zaubersprüche", + "spellPaymentOptions": { "auto": "Automatisch", "autoExceptSacrificialMana": "Vor dem Opfern fragen", "manual": "Manuell" }, "nativeEngine": "Native Engine für lokale Spiele und gehostete Mehrspielerpartien verwenden", "nativeEngineDescription": "Verwende die heruntergeladene Engine für lokale KI-Spiele und Spiele, die du über eine Lobby hostest. Gäste verbinden sich weiterhin über die Peer-to-Peer-Verbindung der Lobby. Fällt auf die integrierte Engine zurück, wenn sie nicht verfügbar ist.", "boardBackground": "Spielfeldhintergrund", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 2276c376b6..d22455282f 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -5,6 +5,13 @@ "cantActivateDuring": "Can't be activated right now", "prohibited": "Activating this ability is prohibited" }, + "manaSourceSelection": { + "title": "Choose a mana source", + "description": "This mana ability requires sacrificing a permanent.", + "sacrifice": "Sacrifice", + "back": "Back to mana payment", + "unknownSource": "Mana source" + }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index c24b30e2a7..c8dd7ccf2e 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -29,6 +29,11 @@ "logDefault": "Log Default", "spellPayment": "Spell Payment", "manualManaPayment": "Manual mana payment for spells", + "spellPaymentOptions": { + "auto": "Automatic", + "autoExceptSacrificialMana": "Ask before sacrificing", + "manual": "Manual" + }, "nativeEngine": "Use native engine for local games and hosted multiplayer", "nativeEngineDescription": "Use the downloaded engine for local AI games and games you host through a lobby. Guests still connect through the lobby's peer-to-peer connection. Falls back to the built-in engine when unavailable.", "boardBackground": "Board Background", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 3d15c692c8..26e0897409 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "No puede activarse ahora", "prohibited": "Activar esta habilidad está prohibido" }, + "manaSourceSelection": { "title": "Elige una fuente de maná", "description": "Esta habilidad de maná requiere sacrificar un permanente.", "sacrifice": "Sacrificar", "back": "Volver al pago de maná", "unknownSource": "Fuente de maná" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/es/settings.json b/client/src/i18n/locales/es/settings.json index 56d809adff..e23ea850ed 100644 --- a/client/src/i18n/locales/es/settings.json +++ b/client/src/i18n/locales/es/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro predeterminado", "spellPayment": "Pago de hechizos", "manualManaPayment": "Pago manual de maná para hechizos", + "spellPaymentOptions": { "auto": "Automático", "autoExceptSacrificialMana": "Preguntar antes de sacrificar", "manual": "Manual" }, "nativeEngine": "Usar el motor nativo para partidas locales y multijugador alojado", "nativeEngineDescription": "Usa el motor descargado para partidas locales contra IA y partidas que alojes mediante una sala. Los invitados siguen conectándose mediante la conexión punto a punto de la sala. Usa el motor integrado cuando no está disponible.", "boardBackground": "Fondo del tablero", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 4435e8d67d..af9fcad115 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Ne peut pas être activée pour l'instant", "prohibited": "L'activation de cette capacité est interdite" }, + "manaSourceSelection": { "title": "Choisir une source de mana", "description": "Cette capacité de mana demande de sacrifier un permanent.", "sacrifice": "Sacrifier", "back": "Retour au paiement de mana", "unknownSource": "Source de mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/fr/settings.json b/client/src/i18n/locales/fr/settings.json index f3c25aaf37..35f06d13eb 100644 --- a/client/src/i18n/locales/fr/settings.json +++ b/client/src/i18n/locales/fr/settings.json @@ -29,6 +29,7 @@ "logDefault": "Journal par défaut", "spellPayment": "Paiement des sorts", "manualManaPayment": "Paiement manuel du mana pour les sorts", + "spellPaymentOptions": { "auto": "Automatique", "autoExceptSacrificialMana": "Demander avant de sacrifier", "manual": "Manuel" }, "nativeEngine": "Utiliser le moteur natif pour les parties locales et le multijoueur hébergé", "nativeEngineDescription": "Utilisez le moteur téléchargé pour les parties locales contre l’IA et les parties que vous hébergez via un salon. Les invités se connectent toujours via la connexion pair à pair du salon. Utilise le moteur intégré lorsqu’il n’est pas disponible.", "boardBackground": "Arrière-plan du plateau", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 30a3c768c1..24aff1b0f9 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Non può essere attivata ora", "prohibited": "L'attivazione di questa abilità è proibita" }, + "manaSourceSelection": { "title": "Scegli una fonte di mana", "description": "Questa abilità di mana richiede di sacrificare un permanente.", "sacrifice": "Sacrifica", "back": "Torna al pagamento del mana", "unknownSource": "Fonte di mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/it/settings.json b/client/src/i18n/locales/it/settings.json index 0f969c6722..ab047477e3 100644 --- a/client/src/i18n/locales/it/settings.json +++ b/client/src/i18n/locales/it/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro predefinito", "spellPayment": "Pagamento magie", "manualManaPayment": "Pagamento manuale del mana per le magie", + "spellPaymentOptions": { "auto": "Automatico", "autoExceptSacrificialMana": "Chiedi prima di sacrificare", "manual": "Manuale" }, "nativeEngine": "Usa il motore nativo per partite locali e multiplayer ospitato", "nativeEngineDescription": "Usa il motore scaricato per le partite locali contro l'IA e le partite che ospiti tramite una lobby. Gli ospiti continuano a connettersi tramite la connessione peer-to-peer della lobby. Usa il motore integrato quando non è disponibile.", "boardBackground": "Sfondo del campo", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index c7acb281b3..1b28510c77 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Nie można teraz aktywować", "prohibited": "Aktywacja tej zdolności jest zabroniona" }, + "manaSourceSelection": { "title": "Wybierz źródło many", "description": "Ta zdolność many wymaga poświęcenia permanenta.", "sacrifice": "Poświęć", "back": "Wróć do płatności many", "unknownSource": "Źródło many" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/pl/settings.json b/client/src/i18n/locales/pl/settings.json index 969d3dd454..1f3db39bf0 100644 --- a/client/src/i18n/locales/pl/settings.json +++ b/client/src/i18n/locales/pl/settings.json @@ -29,6 +29,7 @@ "logDefault": "Domyślny dziennik", "spellPayment": "Płatność za czary", "manualManaPayment": "Ręczna płatność many za czary", + "spellPaymentOptions": { "auto": "Automatycznie", "autoExceptSacrificialMana": "Pytaj przed poświęceniem", "manual": "Ręcznie" }, "nativeEngine": "Używaj natywnego silnika w lokalnych grach i hostowanym trybie wieloosobowym", "nativeEngineDescription": "Używaj pobranego silnika do lokalnych gier z AI i gier hostowanych przez lobby. Goście nadal łączą się przez połączenie peer-to-peer lobby. Gdy silnik jest niedostępny, używany jest wbudowany silnik.", "boardBackground": "Tło pola bitwy", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index be12235a48..71fab73662 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Não pode ser ativada agora", "prohibited": "Ativar esta habilidade é proibido" }, + "manaSourceSelection": { "title": "Escolha uma fonte de mana", "description": "Esta habilidade de mana exige sacrificar uma permanente.", "sacrifice": "Sacrificar", "back": "Voltar ao pagamento de mana", "unknownSource": "Fonte de mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/pt/settings.json b/client/src/i18n/locales/pt/settings.json index 508924e125..a7d3cac0ca 100644 --- a/client/src/i18n/locales/pt/settings.json +++ b/client/src/i18n/locales/pt/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro Padrão", "spellPayment": "Pagamento de Mágicas", "manualManaPayment": "Pagamento manual de mana para mágicas", + "spellPaymentOptions": { "auto": "Automático", "autoExceptSacrificialMana": "Perguntar antes de sacrificar", "manual": "Manual" }, "nativeEngine": "Usar o motor nativo para partidas locais e multijogador hospedado", "nativeEngineDescription": "Use o motor baixado para partidas locais contra IA e partidas hospedadas por você em um lobby. Os convidados continuam a se conectar pela conexão ponto a ponto do lobby. Usa o motor integrado quando não está disponível.", "boardBackground": "Plano de Fundo do Tabuleiro", diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 8ba5036ed3..af7a4ad0e7 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -37,7 +37,7 @@ const viewerInteractionWithProducedMana = { describe("encodeWireMessage / decodeWireMessage", () => { it("pins the P2P wire protocol to v16", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(16); + expect(WIRE_PROTOCOL_VERSION).toBe(17); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 1ed4258c2e..4288b9f8c2 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * 4 — Archenemy derived view and scheme deck payloads * 5 — CardPredicateGuessMade game event shape * 13 — Actor-scoped priority-passing settings and filtered per-player state. + * 17 — Sacrificial-mana source selection action and waiting-state snapshots. * 12 — Connive exact subject snapshots and resident paused post-replacement * drains changed P2P GameState snapshots. * 11 — Serialized GameState trigger provenance and paused logical zone-change owners. @@ -91,7 +92,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 16 as const; +export const WIRE_PROTOCOL_VERSION = 17 as const; export type P2PMessage = | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 4564b8d73a..37a9084642 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -71,7 +71,7 @@ import { HelpSheet } from "../components/help/HelpSheet.tsx"; import { GameLogPanel } from "../components/log/GameLogPanel.tsx"; import { ChooseXValueUI } from "../components/mana/ChooseXValueUI.tsx"; import { AssistPaymentUI } from "../components/mana/AssistPaymentUI.tsx"; -import { ManaPaymentUI } from "../components/mana/ManaPaymentUI.tsx"; +import { ManaPaymentUI, ManaSourceSelectionUI } from "../components/mana/ManaPaymentUI.tsx"; import { PayAmountChoiceUI } from "../components/mana/PayAmountChoiceUI.tsx"; import { RichLabel } from "../components/mana/RichLabel.tsx"; import { CardDataMissingModal } from "../components/modal/CardDataMissingModal.tsx"; @@ -1770,6 +1770,8 @@ function GamePageContent({ {waitingFor != null && MANA_PAYMENT_WAITING_FOR_TYPES.has(waitingFor.type) && canActForWaitingState && } + {waitingFor?.type === "ManaSourceSelection" && + canActForWaitingState && } {waitingFor?.type === "ChooseXValue" && canActForWaitingState && } {waitingFor?.type === "PayAmountChoice" && diff --git a/client/src/stores/preferencesStore.ts b/client/src/stores/preferencesStore.ts index 3e81640d5f..31beb1c239 100644 --- a/client/src/stores/preferencesStore.ts +++ b/client/src/stores/preferencesStore.ts @@ -89,7 +89,7 @@ export type CommandZoneDisplay = "compact" | "inline" | "auto"; * tri-state "auto" precedent. Lands and support each carry their own value. */ export type ZoneCollapseMode = "auto" | "on" | "off"; export type TapRotation = "mtga" | "classic"; -export type SpellPaymentMode = "auto" | "manual"; +export type SpellPaymentMode = "auto" | "autoExceptSacrificialMana" | "manual"; /** Which screen edge the resolving-stack panel docks to (and collapses toward). * User-chosen so a player can keep the stack off whichever side of the * battlefield they care about — e.g. dock left to free the right action rail. */ @@ -785,7 +785,7 @@ export const usePreferencesStore = create }), { name: "phase-preferences", - version: 28, + version: 29, // v0 → v1: flat aiDifficulty + aiDeckName become aiSeats[0]. // v1 → v2: discrete animationSpeed/combatPacing enums become numeric // animationSpeedMultiplier/combatPacingMultiplier. @@ -845,6 +845,9 @@ export const usePreferencesStore = create // preserves the intended enabled-by-default behavior. // v27 → v28: Add showCardPreviewFooter; legacy stores default to true // via the shallow merge, preserving the prior presentation. + // v28 → v29: Add the sacrificial-mana-aware automatic mode. Existing + // values remain valid; malformed persisted values normalize to + // the legacy automatic behavior below. migrate: (persisted: unknown, version: number) => { if (!persisted || typeof persisted !== "object") return persisted; let migrated = persisted as Record; @@ -938,6 +941,17 @@ export const usePreferencesStore = create migrated = { ...migrated, spellPaymentMode: "auto" }; } + if (version < 29) { + const mode = (migrated as { spellPaymentMode?: unknown }).spellPaymentMode; + migrated = { + ...migrated, + spellPaymentMode: + mode === "auto" || mode === "autoExceptSacrificialMana" || mode === "manual" + ? mode + : "auto", + }; + } + if (version < 9) { const lng = (migrated as { language?: unknown }).language; migrated = { diff --git a/client/src/viewmodel/__tests__/cardActionChoice.test.ts b/client/src/viewmodel/__tests__/cardActionChoice.test.ts index 869935a1a9..484728f715 100644 --- a/client/src/viewmodel/__tests__/cardActionChoice.test.ts +++ b/client/src/viewmodel/__tests__/cardActionChoice.test.ts @@ -66,6 +66,7 @@ function tapLandAction(objectId: number): Extract mana_payment_actions(state, *player, *convoke_mode), + WaitingFor::ManaSourceSelection { + player, options, .. + } => { + let mut actions = options + .iter() + .cloned() + .map(|selection| { + candidate( + GameAction::ActivateManaSource { selection }, + TacticalClass::Mana, + Some(*player), + ) + }) + .collect::>(); + actions.push(candidate( + GameAction::BackToManaPayment, + TacticalClass::Pass, + Some(*player), + )); + actions + } WaitingFor::MoveCountersDistribution { player, available, @@ -3362,7 +3383,10 @@ fn semantic_candidate_actions_with_probe( let has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) && state.pending_cast.is_some()); - if has_pending_cast { + let allows_cancel_cast = state.waiting_for.allows_cancel_cast() + || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) + && state.pending_cast.is_some()); + if has_pending_cast && allows_cancel_cast { if let Some(player) = state.waiting_for.acting_player() { actions.push(candidate( GameAction::CancelCast, diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index e833014569..f3b72621c6 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -1219,7 +1219,7 @@ fn legality_equivalence_key( key.is_blocked = crate::game::restrictions::is_source_blocked(state, *source_id); Some(key) } - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { if poison.has_activation { return None; } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index f27f8d2e27..1c9d905387 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -188,6 +188,16 @@ fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { }, }, ) + | ( + WaitingFor::Priority { .. }, + GameAction::ActivateManaSource { + selection: + crate::types::mana::ManaSourceSelection { + source: crate::types::identifiers::ObjectIncarnationRef { object_id, .. }, + .. + }, + }, + ) | (WaitingFor::Priority { .. }, GameAction::UntapLandForMana { object_id }) | ( WaitingFor::Priority { .. }, @@ -1010,6 +1020,18 @@ fn grouped_mana_requires_priority(state: &GameState, player: PlayerId) -> bool { &aura_sources, &mana_activation_gates, ), + GameAction::ActivateManaSource { selection } => { + selection.ability_index.is_some_and(|ability_index| { + activate_mana_action_would_queue_non_mana_trigger( + state, + player, + selection.source.object_id, + ability_index, + &aura_sources, + &mana_activation_gates, + ) + }) + } _ => false, }) } @@ -1084,6 +1106,8 @@ fn classify_flat_priority_action(action: &GameAction) -> FlatPriorityActionClass | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } @@ -1436,6 +1460,21 @@ fn beneficial_mana_tap_trigger_hold( } *source_id } + GameAction::ActivateManaSource { selection } => { + let Some(ability_index) = selection.ability_index else { + return false; + }; + let object_id = selection.source.object_id; + let has_tap = state + .objects + .get(&object_id) + .and_then(|obj| obj.abilities.get(ability_index)) + .is_some_and(|ability| mana_sources::has_tap_component(&ability.cost)); + if !has_tap { + return false; + } + object_id + } _ => return false, }; diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index c102286df8..60cb96d141 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -92,7 +92,9 @@ pub fn classify_payment_continuation(state: &GameState) -> PaymentContinuationSt match &state.waiting_for { // CR 601.2g–h: during the ordinary mana-payment window, the visible // payer and live pending cast jointly identify the payment root. - WaitingFor::ManaPayment { player, .. } => classify_global_root(state, *player), + WaitingFor::ManaPayment { player, .. } | WaitingFor::ManaSourceSelection { player, .. } => { + classify_global_root(state, *player) + } // CR 601.2f–h: submitting Phyrexian choices remains part of the same // cost payment. The prompt's object must agree with the announced root. WaitingFor::PhyrexianPayment { @@ -442,6 +444,15 @@ fn classify_deferred_life_root( PaymentContinuationUnsupported::PayerMismatch, ) } + ManaAbilityResume::ManaSourceSelection { + player: selection_player, + .. + } if selection_player == player => classify_global_root(state, *player), + ManaAbilityResume::ManaSourceSelection { .. } => { + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::PayerMismatch, + ) + } ManaAbilityResume::PhyrexianCastPayment { .. } | ManaAbilityResume::FinalizePendingManaPayment { .. } => { PaymentContinuationState::UnsupportedAffiliated( @@ -532,6 +543,9 @@ fn record_root_from_resume( ManaAbilityResume::ManaPayment { outer_player: None, .. } => return Err(PaymentContinuationUnsupported::MissingOuterPayer), + ManaAbilityResume::ManaSourceSelection { player, .. } => { + Some(root_from_global(state, *player)?) + } ManaAbilityResume::PhyrexianCastPayment { caster, .. } => { Some(root_from_global(state, *caster)?) } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a932575ca3..7b567be050 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -15060,6 +15060,7 @@ fn auto_tap_and_pay_cost_excluding( Some(source_id), ctx, excluded_sources, + None, resume, parent, ); diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index fb10c8f5a8..3a7eaf1e3e 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -8654,6 +8654,26 @@ pub(super) fn pay_and_push_adventure( } state.pending_cast = Some(Box::new(pending)); + if payment_mode == CastPaymentMode::AutoExceptSacrificialMana { + auto_tap_non_sacrificial_mana_sources(state, player, cost, events, object_id); + if pending_cost_is_payable_from_pool(state, player) { + return finalize_automatic_mana_payment(state, player, events); + } + let options = super::mana_sources::activatable_mana_source_selections(state, player) + .into_iter() + .filter(|selection| { + selection.penalty == super::mana_sources::ManaSourcePenalty::Sacrifices + }) + .collect::>(); + if options.is_empty() { + return enter_payment_step(state, player, None, events); + } + return Ok(WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode: None, + }); + } finalize_automatic_mana_payment(state, player, events) } @@ -10375,6 +10395,7 @@ pub(super) fn auto_tap_mana_sources_excluding( None, None, None, + None, ); } @@ -10416,6 +10437,7 @@ pub(super) fn auto_tap_mana_sources_with_context_and_resume( deprioritize_source, payment_context, &HashSet::new(), + None, resume, None, ); @@ -10432,6 +10454,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_and_resume( deprioritize_source: Option, payment_context: Option<&PaymentContext<'_>>, excluded_sources: &HashSet, + excluded_penalty: Option, resume: Option<&ManaAbilityResume>, parent: Option<&ManaAbilityCostParent>, ) { @@ -10442,6 +10465,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_and_resume( events, deprioritize_source, excluded_sources, + excluded_penalty, payment_context, None, None, @@ -10466,6 +10490,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding( events, deprioritize_source, excluded_sources, + None, payment_context, None, None, @@ -10474,6 +10499,63 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding( ); } +/// CR 601.2g-h + CR 605.3b: Apply the existing automatic planner while +/// excluding sacrificial activation rows. This is the safe first leg of +/// `AutoExceptSacrificialMana`; the caller retains the pending cast and offers +/// the excluded capabilities explicitly if this leg cannot finish payment. +pub(super) fn auto_tap_non_sacrificial_mana_sources( + state: &mut GameState, + player: PlayerId, + cost: &crate::types::mana::ManaCost, + events: &mut Vec, + source_id: ObjectId, +) { + let spell_meta = super::casting::build_spell_meta(state, player, source_id); + let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell); + auto_tap_mana_sources_inner( + state, + player, + cost, + events, + Some(source_id), + &HashSet::new(), + Some(mana_sources::ManaSourcePenalty::Sacrifices), + spell_ctx.as_ref(), + None, + None, + None, + None, + ); +} + +pub(super) fn pending_cost_is_payable_from_pool(state: &GameState, player: PlayerId) -> bool { + let Some(pending) = state.pending_cast.as_deref() else { + return false; + }; + let spell_meta = super::casting::build_spell_meta(state, player, pending.object_id); + let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell); + let any_color = super::casting::player_can_spend_as_any_color_for_payment( + state, + player, + Some(pending.object_id), + spell_ctx.as_ref(), + ); + let permissions = + super::static_abilities::build_cost_permission_context(state, player, any_color); + state + .players + .iter() + .find(|candidate| candidate.id == player) + .is_some_and(|candidate| { + mana_payment::can_pay_for_spell( + &candidate.mana_pool, + &pending.cost, + spell_ctx.as_ref(), + permissions, + ) + }) +} + #[derive(Debug, Clone)] pub(super) struct AutoTapSourceCache { player: PlayerId, @@ -10503,7 +10585,7 @@ pub(super) fn build_auto_tap_source_cache( crate::game::perf_counters::record_auto_tap_source_cache_build(); AutoTapSourceCache { player, - sources: collect_sorted_auto_tap_source_options(state, player, None, &HashSet::new()), + sources: collect_sorted_auto_tap_source_options(state, player, None, &HashSet::new(), None), } } @@ -10525,6 +10607,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_cached( events, deprioritize_source, excluded_sources, + None, payment_context, None, source_cache, @@ -10538,6 +10621,7 @@ fn collect_sorted_auto_tap_source_options( player: PlayerId, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, ) -> Vec { use crate::types::card_type::{CoreType, Supertype}; @@ -10593,6 +10677,7 @@ fn collect_sorted_auto_tap_source_options( } }) .flatten() + .filter(|option| excluded_penalty.is_none_or(|penalty| option.penalty != penalty)) .collect(); // CR 605.3b: Auto-tap sort key. Tier layout (the enum factors the two @@ -10675,11 +10760,13 @@ fn cached_auto_tap_sources<'a>( player: PlayerId, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, sub_cost_demand: Option<&crate::game::mana_payment::ColorDemand>, ) -> Option<&'a [ManaSourceOption]> { let cache = source_cache?; if cache.is_for_player(player) && excluded_sources.is_empty() + && excluded_penalty.is_none() && sub_cost_demand.is_none() && deprioritize_source.is_none_or(|source_id| !cache.contains_source(source_id)) { @@ -10699,6 +10786,7 @@ fn auto_tap_mana_sources_inner( events: &mut Vec, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, payment_context: Option<&PaymentContext<'_>>, sub_cost_demand: Option<&crate::game::mana_payment::ColorDemand>, source_cache: Option<&AutoTapSourceCache>, @@ -10756,6 +10844,7 @@ fn auto_tap_mana_sources_inner( player, deprioritize_source, excluded_sources, + excluded_penalty, sub_cost_demand, ) { cached @@ -10765,6 +10854,7 @@ fn auto_tap_mana_sources_inner( player, deprioritize_source, excluded_sources, + excluded_penalty, ); &available_buf }; @@ -11111,6 +11201,7 @@ fn auto_tap_mana_sources_inner( events, Some(option.object_id), excluded, + excluded_penalty, Some(&activation_ctx), demand.as_ref(), None, @@ -12871,6 +12962,7 @@ pub(super) fn maybe_pause_for_phyrexian_choice( Some(source_id), payment_context, excluded_sources, + None, resume, None, ); diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 5174665a0a..48dfdb0649 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -423,6 +423,11 @@ fn effect_pay_cost_mana_resume( if let WaitingFor::ManaPayment { player, convoke_mode, + } + | WaitingFor::ManaSourceSelection { + player, + convoke_mode, + .. } = &state.waiting_for { return Some(ManaAbilityResume::ManaPayment { diff --git a/crates/engine/src/game/derived.rs b/crates/engine/src/game/derived.rs index 314a726d0d..f353b01279 100644 --- a/crates/engine/src/game/derived.rs +++ b/crates/engine/src/game/derived.rs @@ -289,6 +289,9 @@ pub fn derive_display_state(state: &mut GameState) { state.has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) && state.pending_cast.is_some()); + state.allows_cancel_cast = state.waiting_for.allows_cancel_cast() + || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) + && state.pending_cast.is_some()); // Invariant: the two storage sites for "am I mid-cast" must agree. If // `waiting_for` says we're mid-cast, `GameState::pending_cast` must be diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index 9998257446..de3bbc98a4 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -344,7 +344,9 @@ fn mark_prepare_copy_cancel_rollback( if matches!( waiting, - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } ) { if let Some(pending) = state.pending_cast.as_mut() { debug_assert_eq!( diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 7ff86d769a..d92b89085d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4477,6 +4477,48 @@ fn apply_action( ); waiting_for } + (WaitingFor::Priority { player }, GameAction::ActivateManaSource { selection }) => { + if state.priority_player + != turn_control::authorized_submitter_for_player(state, *player) + { + return Err(EngineError::NotYourPriority); + } + if state + .objects + .get(&selection.source.object_id) + .is_some_and(|object| { + object + .card_types + .core_types + .contains(&crate::types::card_type::CoreType::Land) + }) + { + return Err(EngineError::ActionNotAllowed( + "Land mana abilities use TapLandForMana".to_string(), + )); + } + let events_before = events.len(); + let waiting_for = mana_sources::activate_mana_source_selection( + state, + *player, + &selection, + &mut events, + ManaAbilityResume::Priority, + )?; + triggers::resolve_tap_mana_triggers_inline(state, &mut events, events_before); + if let Some(ability_index) = selection.ability_index { + record_mana_loop_action_step( + state, + *player, + selection.source.object_id, + crate::types::game_state::LoopAction::Activate { + source_id: selection.source.object_id, + ability_index, + }, + ); + } + waiting_for + } (WaitingFor::Priority { player }, GameAction::UntapLandForMana { object_id }) => { if state.priority_player != turn_control::authorized_submitter_for_player(state, *player) @@ -6055,6 +6097,49 @@ fn apply_action( None => WaitingFor::Priority { player }, } } + ( + WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, + GameAction::BackToManaPayment, + ) => { + // The selection window never consumes mana or changes pins. Restore + // the exact payment state rather than re-running the planner. + let _ = options; + WaitingFor::ManaPayment { + player: *player, + convoke_mode: *convoke_mode, + } + } + ( + WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, + GameAction::ActivateManaSource { selection }, + ) => { + if !options.contains(&selection) { + return Err(EngineError::ActionNotAllowed( + "Mana source was not offered for this payment".to_string(), + )); + } + let events_before = events.len(); + let waiting_for = mana_sources::activate_mana_source_selection( + state, + *player, + &selection, + &mut events, + ManaAbilityResume::ManaPayment { + outer_player: Some(*player), + convoke_mode: *convoke_mode, + }, + )?; + triggers::resolve_tap_mana_triggers_inline(state, &mut events, events_before); + waiting_for + } (WaitingFor::ChooseXValue { player, .. }, GameAction::CancelCast) => { // CR 601.2f + CR 601.2i: Caster may back out before committing to an // X value. Pop the stack entry placed at announcement and restore. diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 68a1a2b932..b7826e2004 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -248,7 +248,9 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H | WaitingFor::CommanderZoneChoice { .. } | WaitingFor::UntapChoice { .. } => HumanResponseModel::DirectChoices, WaitingFor::BetweenGamesSideboard { .. } => HumanResponseModel::SideboardPartition, - WaitingFor::ManaPayment { .. } => HumanResponseModel::DirectChoices, + WaitingFor::ManaPayment { .. } | WaitingFor::ManaSourceSelection { .. } => { + HumanResponseModel::DirectChoices + } WaitingFor::LoopShortcut { .. } => HumanResponseModel::LoopShortcut, WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } @@ -334,6 +336,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { Some(InteractionSlotKind::OpeningBottom), ), WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } | WaitingFor::AssistPayment { .. } | WaitingFor::DefilerPayment { .. } | WaitingFor::UnlessPayment { .. } @@ -2008,6 +2011,20 @@ fn direct_choice_projection( } mana_payment_direct_actions(state, *player, *convoke_mode)? } + WaitingFor::ManaSourceSelection { + player, options, .. + } => { + if *player != semantic_owner { + return Err(InteractionReasonCode::InvalidAuthorityState); + } + let mut actions = options + .iter() + .cloned() + .map(|selection| GameAction::ActivateManaSource { selection }) + .collect::>(); + actions.push(GameAction::BackToManaPayment); + actions + } WaitingFor::PrecastCopyShortcutOffer { epoch, route_count, .. } => { @@ -3351,6 +3368,7 @@ fn selection_projection( | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } | WaitingFor::AssistChoosePlayer { .. } | WaitingFor::AssistPayment { .. } | WaitingFor::ChooseXValue { .. } @@ -3873,6 +3891,7 @@ fn object_property_code(property: ObjectProperty) -> InteractionObjectProperty { fn cast_payment_mode_code(mode: CastPaymentMode) -> &'static str { match mode { CastPaymentMode::Auto => "auto", + CastPaymentMode::AutoExceptSacrificialMana => "autoExceptSacrificialMana", CastPaymentMode::Manual => "manual", } } @@ -4146,6 +4165,7 @@ fn project_action_payload( match action { GameAction::PassPriority | GameAction::CancelCast + | GameAction::BackToManaPayment | GameAction::KeepAllCopyTargets | GameAction::RollPlanarDie | GameAction::CompanionToHand @@ -4157,7 +4177,7 @@ fn project_action_payload( GameAction::ChooseEntryAttackTarget { target } => { push_attack_target_surface(surfaces, state, target, InteractionRoleCode::AttackTarget) } - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { let Some(player) = state .objects .get(&selection.source.object_id) @@ -4166,7 +4186,7 @@ fn project_action_payload( return; }; let Ok(option) = - mana_sources::live_land_mana_option_for_selection(state, player, selection) + mana_sources::live_mana_source_option_for_selection(state, player, selection) else { return; }; @@ -4969,6 +4989,8 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::MulliganDecision { .. } => InteractionActionCode::MulliganDecision, GameAction::ReorderHand { .. } => InteractionActionCode::ReorderHand, GameAction::TapLandForMana { .. } => InteractionActionCode::TapLandForMana, + GameAction::ActivateManaSource { .. } => InteractionActionCode::ActivateManaSource, + GameAction::BackToManaPayment => InteractionActionCode::BackToManaPayment, GameAction::UntapLandForMana { .. } => InteractionActionCode::UntapLandForMana, GameAction::SpendPoolMana { .. } => InteractionActionCode::SpendPoolMana, GameAction::UnspendPoolMana { .. } => InteractionActionCode::UnspendPoolMana, @@ -5137,7 +5159,7 @@ fn actor_candidates( .filter_map(|candidate| { let mut manual = candidate.clone(); let payment_mode = manual.action.payment_mode_mut()?; - if *payment_mode != CastPaymentMode::Auto { + if *payment_mode == CastPaymentMode::Manual { return None; } *payment_mode = CastPaymentMode::Manual; diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 5795d85d37..941815d24a 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -2752,6 +2752,18 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( convoke_mode, }, )), + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + } => Ok(resume_waiting_for( + player, + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + }, + )), ManaAbilityResume::UnlessPayment { outer_player, cost, @@ -4152,6 +4164,15 @@ pub(crate) fn resume_waiting_for( player: outer_player.unwrap_or(mana_source_controller), convoke_mode, }, + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + } => WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, ManaAbilityResume::UnlessPayment { outer_player, cost, diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index e9370ebee7..aeabe1a72a 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -26,8 +26,8 @@ use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{GameState, ManaAbilityResume, ProductionOverride, WaitingFor}; use crate::types::identifiers::ObjectId; use crate::types::mana::{ - ManaColor, ManaCostShard, ManaPip, ManaRestriction, ManaSourceSelection, ManaType, - PaymentContext, TapsForManaSelection, + ManaColor, ManaCostShard, ManaPip, ManaRestriction, ManaSourceOutput, ManaSourceSelection, + ManaType, PaymentContext, TapsForManaSelection, }; use crate::types::player::PlayerId; use crate::types::zones::Zone; @@ -203,6 +203,7 @@ impl ManaSourceOption { source: crate::types::identifiers::ObjectIncarnationRef::from_object(source), ability_index: self.ability_index, mana_type: self.mana_type, + output: ManaSourceOutput::Concrete(self.mana_type), atomic_combination: self.atomic_combination.clone(), restrictions: self.restrictions.clone(), penalty: self.penalty, @@ -297,6 +298,10 @@ pub fn activatable_mana_actions_for_player(state: &GameState, player: PlayerId) &mana_activation_gates, ) { + // Interactive costs (for example, "Sacrifice an artifact") + // deliberately remain the ordinary activation action during a + // normal ManaPayment window. Its established cost resolver is + // the authority for the subsequent PayCost choice. actions.push(GameAction::ActivateAbility { source_id: object_id, ability_index, @@ -307,6 +312,181 @@ pub fn activatable_mana_actions_for_player(state: &GameState, player: PlayerId) actions } +/// CR 605.3a: Complete semantic capabilities for mana activation, across lands +/// and nonlands. This is intentionally separate from `GameAction` generation: +/// callers can freeze these engine-authored candidates in an interaction and +/// later require an exact fresh match before activation. +pub fn activatable_mana_source_selections( + state: &GameState, + player: PlayerId, +) -> Vec { + let aura_sources = taps_for_mana_trigger_sources(state); + let gates = mana_abilities::ManaActivationGates::compute(state); + let mut selections = Vec::new(); + + for &object_id in &state.battlefield { + let Some(object) = state.objects.get(&object_id) else { + continue; + }; + if object.controller != player { + continue; + } + + let options = current_mana_source_options(state, player, object_id, &aura_sources, &gates); + for option in options { + let Some(selection) = manual_selection_for_option(state, &option) else { + continue; + }; + if !selections.contains(&selection) { + selections.push(selection); + } + } + } + selections.sort_by(|left, right| left.cmp_stable(right)); + selections +} + +/// CR 605.3a: Return the source rows a frozen, explicit mana selection may +/// name. Auto-tap rows stay the base because they preserve land fallback and +/// tap-trigger metadata; then append every other currently legal activated +/// mana ability. Unlike the automatic planner, this includes interactive costs +/// such as "Sacrifice an artifact", because the selection is explicit and the +/// normal `PayCost` flow still resolves that cost after activation. +fn current_mana_source_options( + state: &GameState, + player: PlayerId, + object_id: ObjectId, + aura_sources: &[ObjectId], + gates: &mana_abilities::ManaActivationGates, +) -> Vec { + let Some(object) = state.objects.get(&object_id) else { + return Vec::new(); + }; + if object.zone != Zone::Battlefield || object.controller != player { + return Vec::new(); + } + + let mut options = if object.card_types.core_types.contains(&CoreType::Land) { + activatable_land_mana_options_indexed_gated(state, object_id, player, aura_sources, gates) + } else { + auto_tap_mana_options(state, object_id, player) + }; + + for (ability_index, ability) in object.abilities.iter().enumerate() { + if options + .iter() + .any(|option| option.ability_index == Some(ability_index)) + || ability.kind != AbilityKind::Activated + || !mana_abilities::is_mana_ability(ability) + || !mana_abilities::can_activate_mana_ability_now_gated( + state, + player, + object_id, + ability_index, + ability, + gates, + ) + || !activation_condition_satisfied(state, player, object_id, ability_index, ability) + { + continue; + } + + let penalty = object_mana_ability_penalty(state, object_id, ability); + let source_could_produce_two_or_more_colors = + source_could_produce_two_or_more_colors(state, object_id, player); + for row in emit_source_rows(state, player, object_id, ability_index, ability, true) { + let option = ManaSourceOption { + object_id, + ability_index: Some(ability_index), + mana_type: row.mana_type, + source_could_produce_two_or_more_colors, + penalty, + atomic_combination: row.atomic_combination, + restrictions: row.restrictions, + taps_for_mana_overrides: Vec::new(), + }; + if !options.contains(&option) { + options.push(option); + } + } + } + + options +} + +fn manual_selection_for_option( + state: &GameState, + option: &ManaSourceOption, +) -> Option { + let mut selection = option.semantic_selection(state)?; + let flexible_output = option.ability_index.is_some_and(|ability_index| { + state + .objects + .get(&option.object_id) + .and_then(|object| object.abilities.get(ability_index)) + .is_some_and(|ability| { + matches!( + &*ability.effect, + Effect::Mana { + produced: ManaProduction::AnyOneColor { .. } + | ManaProduction::AnyCombination { .. } + | ManaProduction::ChoiceAmongExiledColors { .. } + | ManaProduction::AnyOneColorAmongPermanents { .. } + | ManaProduction::OpponentLandColors { .. } + | ManaProduction::AnyTypeProduceableBy { .. } + | ManaProduction::AnyCombinationOfObjectColors { .. } + | ManaProduction::AnyInCommandersColorIdentity { .. }, + .. + } + ) + }) + }); + if flexible_output { + // The planner emits one concrete row per color, but a manual activation + // must retain the source capability and let the normal mana-choice + // resolver ask for its color. The colorless marker is intentionally + // inert while `output` is deferred and canonicalizes all planner rows. + selection.mana_type = ManaType::Colorless; + selection.output = ManaSourceOutput::DeferredColorChoice; + } + Some(selection) +} + +/// Resolve an exact generic semantic source selection from fresh candidates. +/// A deferred flexible output legitimately corresponds to several concrete +/// planner rows; they all name the same object/ability capability and therefore +/// collapse to one canonical manual selection above. +pub(crate) fn live_mana_source_option_for_selection( + state: &GameState, + player: PlayerId, + selection: &ManaSourceSelection, +) -> Result { + if !activatable_mana_source_selections(state, player).contains(selection) { + return Err(EngineError::ActionNotAllowed( + "Mana source selection is stale or no longer legal".to_string(), + )); + } + + let aura_sources = taps_for_mana_trigger_sources(state); + let gates = mana_abilities::ManaActivationGates::compute(state); + let object_id = selection.source.object_id; + let options = current_mana_source_options(state, player, object_id, &aura_sources, &gates); + let mut matches = options + .into_iter() + .filter(|option| manual_selection_for_option(state, option).as_ref() == Some(selection)); + let option = matches.next().ok_or_else(|| { + EngineError::ActionNotAllowed( + "Mana source selection is stale or no longer legal".to_string(), + ) + })?; + if matches.any(|other| other.ability_index != option.ability_index) { + return Err(EngineError::InvalidAction( + "Mana source selection ambiguously matches multiple live capabilities".to_string(), + )); + } + Ok(option) +} + /// Re-enumerate the live land-mana rows and resolve one exact semantic /// selection to its current engine-owned option. Zero matches means the /// submitted choice is stale or illegal; multiple matches mean the selector @@ -551,6 +731,27 @@ pub(crate) fn activate_mana_source_option( option: &ManaSourceOption, events: &mut Vec, resume: ManaAbilityResume, +) -> Result { + activate_mana_source_option_with_output( + state, + player, + option, + ManaSourceOutput::Concrete(option.mana_type), + events, + resume, + ) +} + +/// Activate a live source using the preserved output provenance. Deferred +/// flexible outputs deliberately pass no production override, so the existing +/// ManaChoice flow remains the single authority for the eventual color choice. +pub(crate) fn activate_mana_source_option_with_output( + state: &mut GameState, + player: PlayerId, + option: &ManaSourceOption, + output: ManaSourceOutput, + events: &mut Vec, + resume: ManaAbilityResume, ) -> Result { for (trigger_ref, production_override) in &option.taps_for_mana_overrides { state @@ -569,8 +770,12 @@ pub(crate) fn activate_mana_source_option( "Selected mana ability is no longer available".to_string(), ) })?; - let production_override = - super::casting_costs::production_override_for_option(&ability, option); + let production_override = match output { + ManaSourceOutput::Concrete(_) => { + super::casting_costs::production_override_for_option(&ability, option) + } + ManaSourceOutput::DeferredColorChoice => None, + }; mana_abilities::activate_mana_ability( state, option.object_id, @@ -630,6 +835,28 @@ pub(crate) fn activate_mana_source_option( Ok(waiting_for) } +/// CR 605.3a-b: Revalidate and activate a generic mana capability. The caller +/// supplies its resume because this entry point is shared by priority and a +/// spell's mana-payment interaction; it never translates back into +/// `ActivateAbility`, preserving the frozen selection identity. +pub(crate) fn activate_mana_source_selection( + state: &mut GameState, + player: PlayerId, + selection: &ManaSourceSelection, + events: &mut Vec, + resume: ManaAbilityResume, +) -> Result { + let option = live_mana_source_option_for_selection(state, player, selection)?; + activate_mana_source_option_with_output( + state, + player, + &option, + selection.output, + events, + resume, + ) +} + /// CR 107.6 + CR 302.6: True when the cost includes the untap symbol ({Q}). /// Like {T}, a {Q} cost on a creature is gated by summoning sickness (CR 302.6 /// names both symbols) and requires the source to currently be tapped. Matches a diff --git a/crates/engine/src/game/replay.rs b/crates/engine/src/game/replay.rs index 466c72d61a..107a8de281 100644 --- a/crates/engine/src/game/replay.rs +++ b/crates/engine/src/game/replay.rs @@ -212,7 +212,7 @@ mod tests { use crate::types::game_state::{ProductionOverride, WaitingFor}; use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::mana::{ - ManaSourcePenalty, ManaSourceSelection, ManaType, TapsForManaSelection, + ManaSourceOutput, ManaSourcePenalty, ManaSourceSelection, ManaType, TapsForManaSelection, }; use crate::types::match_config::MatchConfig; @@ -267,6 +267,7 @@ mod tests { source, ability_index: None, mana_type: ManaType::Green, + output: crate::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, @@ -291,6 +292,34 @@ mod tests { assert_eq!(restored.actions[0].action, action); } + #[test] + fn legacy_colored_tap_land_action_preserves_its_selected_output() { + let action: GameAction = serde_json::from_value(serde_json::json!({ + "type": "TapLandForMana", + "data": { + "selection": { + "source": { "object_id": 7, "incarnation": 3 }, + "ability_index": null, + "mana_type": "Green", + "atomic_combination": null, + "restrictions": [], + "penalty": "None", + "taps_for_mana": [] + } + } + })) + .expect("pre-output replay actions should deserialize"); + + let GameAction::TapLandForMana { selection } = action else { + panic!("legacy action must retain its TapLandForMana shape"); + }; + assert_eq!( + selection.output, + ManaSourceOutput::Concrete(ManaType::Green), + "a legacy colored row selected its mana_type, not colorless mana" + ); + } + #[test] fn replay_player_reconstructs_every_recorded_index() { let header = two_player_header(99); diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 819c29200b..d9df01b955 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1704,6 +1704,7 @@ impl GameRunner { WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::DeclareAttackers { .. } => "DeclareAttackers", WaitingFor::DeclareBlockers { .. } => "DeclareBlockers", @@ -2475,6 +2476,16 @@ impl<'a> SpellCast<'a> { // CR 601.2h: finalize the (now fully convoke-paid) cost. act_collect(runner, GameAction::PassPriority, &mut events)?; } + WaitingFor::ManaSourceSelection { options, .. } => { + let selection = options.first().cloned().unwrap_or_else(|| { + panic!("ManaSourceSelection must offer at least one source") + }); + act_collect( + runner, + GameAction::ActivateManaSource { selection }, + &mut events, + )?; + } // CR 601.2c: declare one target per slot, in written order. WaitingFor::TargetSelection { pending_cast, @@ -2724,6 +2735,7 @@ fn waiting_for_variant_name(waiting: &WaitingFor) -> &'static str { // borrow-free match. Kept in sync with `GameRunner::waiting_for_kind`. match waiting { WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::ChooseXValue { .. } => "ChooseXValue", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::MultiTargetSelection { .. } => "MultiTargetSelection", diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 27eb8c2719..016086f7a2 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -227,6 +227,18 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { }; a0.cmp_stable(b0) } + GameAction::ActivateManaSource { selection: a0 } => { + let GameAction::ActivateManaSource { selection: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + a0.cmp_stable(b0) + } + GameAction::BackToManaPayment => { + let GameAction::BackToManaPayment = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } GameAction::UntapLandForMana { object_id: a0 } => { let GameAction::UntapLandForMana { object_id: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 42d795b702..aa8df815ea 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -256,6 +256,15 @@ pub enum GameAction { TapLandForMana { selection: ManaSourceSelection, }, + /// CR 605.3a: Activate one exact engine-authored mana-source capability. + /// Unlike the legacy land-only action, this covers mana abilities on every + /// permanent type and preserves the selected output provenance. + ActivateManaSource { + selection: ManaSourceSelection, + }, + /// Return from a sacrificial-mana choice to the exact saved payment state + /// without re-planning or mutating the mana pool. + BackToManaPayment, /// CR 605.3a: Undo a manual mana ability activation — untap source, remove produced mana. /// Only valid for lands in `lands_tapped_for_mana` whose mana hasn't been spent. UntapLandForMana { @@ -1497,6 +1506,7 @@ impl GameAction { matches!( self, GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } | GameAction::UntapLandForMana { .. } // CR 118.3a: pinning/unpinning a pool unit is a mana-payment-window // action; classifying it here keeps it out of AI priority-action @@ -1551,6 +1561,7 @@ impl GameAction { | GameAction::CastSpellAsMadness { object_id, .. } => Some(*object_id), GameAction::ActivateAbility { source_id, .. } => Some(*source_id), GameAction::TapLandForMana { selection } => Some(selection.source.object_id), + GameAction::ActivateManaSource { selection } => Some(selection.source.object_id), GameAction::UntapLandForMana { object_id } => Some(*object_id), // CR 118.3a: act on a pool pip, not a battlefield object. GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } => None, @@ -1588,6 +1599,7 @@ impl GameAction { | GameAction::ChooseReplacement { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast + | GameAction::BackToManaPayment | GameAction::SubmitSideboard { .. } | GameAction::ChoosePlayDraw { .. } | GameAction::ChooseOption { .. } @@ -1870,6 +1882,9 @@ mod tests { }, ability_index: None, mana_type: crate::types::mana::ManaType::Green, + output: crate::types::mana::ManaSourceOutput::Concrete( + crate::types::mana::ManaType::Green, + ), atomic_combination: None, restrictions: Vec::new(), penalty: crate::types::mana::ManaSourcePenalty::None, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 14b04f9678..fb6eda280e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5352,6 +5352,10 @@ fn default_copy_retarget_effect_kind() -> EffectKind { pub enum CastPaymentMode { #[default] Auto, + /// CR 601.2g-h: auto-pay every safe mana row, but stop before an + /// activation whose cost sacrifices a permanent so its controller makes + /// that irreversible payment decision explicitly. + AutoExceptSacrificialMana, Manual, } @@ -6138,6 +6142,16 @@ pub enum ManaAbilityResume { #[serde(default, skip_serializing_if = "Option::is_none")] convoke_mode: Option, }, + /// CR 601.2g-h + CR 605.3b: automatic payment reached a mana source that + /// sacrifices a permanent. The options are the frozen, engine-authored + /// semantic capability snapshot; submission revalidates one exact current + /// candidate before it can mutate the payment. + ManaSourceSelection { + player: PlayerId, + options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + convoke_mode: Option, + }, UnlessPayment { /// The player who owns the outer unless-payment poll. It can differ /// from the controller of a helper mana source activated while paying. @@ -7705,6 +7719,16 @@ pub enum WaitingFor { #[serde(default, skip_serializing_if = "Option::is_none")] convoke_mode: Option, }, + /// CR 601.2g-h + CR 605.3b: automatic payment reached a mana source that + /// sacrifices a permanent. The options are the frozen, engine-authored + /// semantic capability snapshot; submission revalidates one exact current + /// candidate before it can mutate the payment. + ManaSourceSelection { + player: PlayerId, + options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + convoke_mode: Option, + }, /// CR 702.132a: Assist — when casting a spell with assist whose locked total /// cost has a generic component, before the caster pays they MAY choose /// another player to help pay the generic mana. The CASTER acts on this step @@ -9838,6 +9862,7 @@ impl WaitingFor { WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::ChooseXValue { .. } => "ChooseXValue", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::DeclareAttackers { .. } => "DeclareAttackers", @@ -9988,6 +10013,7 @@ impl WaitingFor { | WaitingFor::MeldPairChoice { player, .. } | WaitingFor::MeldAttackTargetChoice { player, .. } | WaitingFor::ManaPayment { player, .. } + | WaitingFor::ManaSourceSelection { player, .. } | WaitingFor::ChooseXValue { player, .. } | WaitingFor::TargetSelection { player, .. } | WaitingFor::DeclareAttackers { player, .. } @@ -10148,12 +10174,13 @@ impl WaitingFor { /// /// Runtime drift detector: the `debug_assert!` in `game::derived` trips /// in tests if a new variant populates `GameState::pending_cast` without - /// being covered here (or by the `ManaPayment` exception in + /// being covered here (or by an external payment-state exception in /// `has_pending_cast`). That is the practical safeguard — the `_ => None` /// wildcard below does not compile-enforce variant coverage on its own. /// - /// Note: `ManaPayment` is the one casting-flow variant that does NOT embed - /// its `PendingCast`. It reads from `GameState::pending_cast` instead so + /// Note: `ManaPayment` and `ManaSourceSelection` are casting-flow variants + /// that do NOT embed their `PendingCast`. They read from + /// `GameState::pending_cast` instead so /// multiplayer visibility filtering (`game::visibility`) can clear /// mid-payment detail for opponents while preserving the public "spell on /// the stack" view elsewhere. `has_pending_cast()` accounts for this. @@ -10228,8 +10255,8 @@ impl WaitingFor { /// Whether this state is part of the casting flow and can be backed out of /// with `CancelCast` (CR 601.2). /// - /// Derived from `pending_cast_ref()` plus the single `ManaPayment` - /// exception (which externalizes its `PendingCast` into + /// Derived from `pending_cast_ref()` plus the external payment states + /// (which externalize their `PendingCast` into /// `GameState::pending_cast`). Centralizing the predicate here guarantees /// that every variant carrying a `PendingCast` is covered — drift between /// data model and predicate is structurally prevented. @@ -10243,9 +10270,18 @@ impl WaitingFor { self.pending_cast_ref().is_some() || matches!( self, - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } ) } + + /// CR 601.2i: Whether the current prompt permits withdrawing the pending + /// spell. Kept distinct from `has_pending_cast`: a cast can be pending at + /// a mandatory choice without granting a cancellation action. + pub fn allows_cancel_cast(&self) -> bool { + self.has_pending_cast() && !matches!(self, WaitingFor::ManaSourceSelection { .. }) + } } /// CR 102.1 + CR 500.1: which turn boundary ends an auto-pass session. @@ -11482,6 +11518,11 @@ pub struct GameState { /// frontend doesn't need to maintain a parallel list of casting states. #[serde(skip_deserializing, default)] pub has_pending_cast: bool, + /// CR 601.2i: display-only authority for whether the current pending cast + /// may be withdrawn. Separate from `has_pending_cast` so choice prompts + /// can expose an in-flight cast without encouraging an illegal cancel. + #[serde(skip_deserializing, default)] + pub allows_cancel_cast: bool, pub lands_played_this_turn: u8, pub max_lands_per_turn: u8, pub priority_pass_count: u8, @@ -16615,6 +16656,7 @@ impl GameState { next_interaction_serial: default_interaction_serial(), active_interaction_slots: Vec::new(), has_pending_cast: false, + allows_cancel_cast: false, lands_played_this_turn: 0, max_lands_per_turn: 1, priority_pass_count: 0, @@ -18064,6 +18106,7 @@ fn _gamestate_partition_is_total(s: &GameState) { next_interaction_serial: _, active_interaction_slots: _, has_pending_cast: _, + allows_cancel_cast: _, lands_played_this_turn: _, max_lands_per_turn: _, priority_pass_count: _, @@ -21409,6 +21452,11 @@ mod tests { player: PlayerId(0), convoke_mode: None, })); + variants.push(Box::new(WaitingFor::ManaSourceSelection { + player: PlayerId(0), + options: Vec::new(), + convoke_mode: None, + })); variants.push(Box::new(WaitingFor::DeclareAttackers { player: PlayerId(0), valid_attacker_ids: vec![], @@ -21691,7 +21739,7 @@ mod tests { mana_reduction: ManaCost::zero(), pending_cast: dummy_pending(), })); - assert_eq!(variants.len(), 36); + assert_eq!(variants.len(), 37); } #[test] @@ -21785,6 +21833,14 @@ mod tests { }; assert!(mana_payment.pending_cast_ref().is_none()); assert!(mana_payment.has_pending_cast()); + + let source_selection = WaitingFor::ManaSourceSelection { + player: PlayerId(0), + options: Vec::new(), + convoke_mode: None, + }; + assert!(source_selection.has_pending_cast()); + assert!(!source_selection.allows_cancel_cast()); } #[test] diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 830f083bb9..b26e041cd2 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -500,6 +500,8 @@ pub enum InteractionActionCode { MulliganDecision, ReorderHand, TapLandForMana, + ActivateManaSource, + BackToManaPayment, UntapLandForMana, SpendPoolMana, UnspendPoolMana, diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index be6749780d..515ae98c9d 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -669,6 +669,19 @@ pub enum ManaSourcePenalty { Sacrifices, } +/// CR 106.1a: The output choice captured for a mana-source activation. +/// +/// `Concrete` is the planner-selected output used by automatic payment. A +/// manually selected flexible producer instead retains `DeferredColorChoice`, +/// so the normal mana-choice interaction chooses its color at activation time +/// rather than silently committing to an arbitrary planner row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ManaSourceOutput { + Concrete(ManaType), + DeferredColorChoice, +} + /// Exact identity of one triggered mana ability that augments a land's own /// production when that land is tapped for mana. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -678,24 +691,62 @@ pub struct TapsForManaSelection { pub production_override: ProductionOverride, } -/// Stable semantic selection for one currently activatable land-mana row. +/// Stable semantic selection for one currently activatable mana-source row. /// /// The action carries every field that can change legality or resolution, but /// the reducer never trusts those fields directly: it enumerates current live /// options and requires one exact semantic match before activating that live /// option. `ObjectIncarnationRef` prevents a stale choice from rebinding to an /// object that left and re-entered the battlefield (CR 400.7). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ManaSourceSelection { pub source: ObjectIncarnationRef, pub ability_index: Option, pub mana_type: ManaType, + pub output: ManaSourceOutput, pub atomic_combination: Option>, pub restrictions: Vec, pub penalty: ManaSourcePenalty, pub taps_for_mana: Vec, } +impl<'de> Deserialize<'de> for ManaSourceSelection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireSelection { + source: ObjectIncarnationRef, + ability_index: Option, + mana_type: ManaType, + #[serde(default)] + output: Option, + atomic_combination: Option>, + restrictions: Vec, + penalty: ManaSourcePenalty, + taps_for_mana: Vec, + } + + let wire = WireSelection::deserialize(deserializer)?; + Ok(Self { + source: wire.source, + ability_index: wire.ability_index, + mana_type: wire.mana_type, + // `TapLandForMana` actions saved before output provenance existed + // selected this exact row's `mana_type`; preserve that choice on + // replay instead of silently turning every old colored row colorless. + output: wire + .output + .unwrap_or(ManaSourceOutput::Concrete(wire.mana_type)), + atomic_combination: wire.atomic_combination, + restrictions: wire.restrictions, + penalty: wire.penalty, + taps_for_mana: wire.taps_for_mana, + }) + } +} + impl ManaSourceSelection { /// Stable total order for the action wire. The non-`Ord` semantic fields /// are compared by explicit closed-enum comparators below; debug strings or @@ -705,6 +756,7 @@ impl ManaSourceSelection { .cmp(&other.source) .then_with(|| self.ability_index.cmp(&other.ability_index)) .then_with(|| self.mana_type.cmp(&other.mana_type)) + .then_with(|| cmp_mana_source_output(self.output, other.output)) .then_with(|| self.atomic_combination.cmp(&other.atomic_combination)) .then_with(|| cmp_mana_restriction_slices(&self.restrictions, &other.restrictions)) .then_with(|| cmp_mana_source_penalty(self.penalty, other.penalty)) @@ -712,6 +764,21 @@ impl ManaSourceSelection { } } +fn cmp_mana_source_output(left: ManaSourceOutput, right: ManaSourceOutput) -> std::cmp::Ordering { + match (left, right) { + (ManaSourceOutput::Concrete(a), ManaSourceOutput::Concrete(b)) => a.cmp(&b), + (ManaSourceOutput::DeferredColorChoice, ManaSourceOutput::DeferredColorChoice) => { + std::cmp::Ordering::Equal + } + (ManaSourceOutput::Concrete(_), ManaSourceOutput::DeferredColorChoice) => { + std::cmp::Ordering::Less + } + (ManaSourceOutput::DeferredColorChoice, ManaSourceOutput::Concrete(_)) => { + std::cmp::Ordering::Greater + } + } +} + fn cmp_slice_by( left: &[T], right: &[T], diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 359b440d71..7e3c175c8b 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -63,8 +63,8 @@ pub use keywords::{Keyword, PartnerType, ProtectionTarget}; pub use layers::{ActiveContinuousEffect, Layer}; pub use log::{GameLogEntry, LogCategory, LogSegment}; pub use mana::{ - ManaColor, ManaCost, ManaCostShard, ManaPool, ManaRestriction, ManaSourcePenalty, - ManaSourceSelection, ManaType, ManaUnit, SpellMeta, TapsForManaSelection, + ManaColor, ManaCost, ManaCostShard, ManaPool, ManaRestriction, ManaSourceOutput, + ManaSourcePenalty, ManaSourceSelection, ManaType, ManaUnit, SpellMeta, TapsForManaSelection, }; pub use match_config::{ BetweenGamesPrompt, DeckCardCount, MatchConfig, MatchPhase, MatchScore, MatchType, diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 3788c61d9d..e681f411b3 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 446cbd2097..f708b60fb3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1134,6 +1134,7 @@ mod s07_coiling_rebirth_full_cast; mod s07_create_token_sequence_middle; mod s07_malamet_castpath; mod s07_this_way_conditions; +mod sacrificial_mana_choice; mod sakashima_of_a_thousand_faces_retains_other_abilities; mod same_is_true_type_statics; mod sandman_reanimate_self_and_land_s25; diff --git a/crates/engine/tests/integration/sacrificial_mana_choice.rs b/crates/engine/tests/integration/sacrificial_mana_choice.rs new file mode 100644 index 0000000000..12ef9a6510 --- /dev/null +++ b/crates/engine/tests/integration/sacrificial_mana_choice.rs @@ -0,0 +1,341 @@ +use engine::ai_support::candidate_actions; +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaContribution, ManaProduction, + QuantityExpr, SacrificeCost, TargetFilter, TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, ManaChoice, PayCostKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost, ManaType}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); + +fn sacrificial_mana_ability(cost: AbilityCost, color: ManaColor) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![color], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(cost) +} + +fn any_one_color_sacrificial_mana_ability(cost: AbilityCost) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: vec![ManaColor::Black, ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(cost) +} + +fn begin_sacrificial_payment(runner: &mut GameRunner, spell: ObjectId) { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::AutoExceptSacrificialMana, + }) + .expect("the production cast path should stop before sacrificial mana"); +} + +fn offered_selection( + runner: &GameRunner, + source: ObjectId, +) -> engine::types::mana::ManaSourceSelection { + let WaitingFor::ManaSourceSelection { options, .. } = &runner.state().waiting_for else { + panic!( + "expected sacrificial mana prompt from the cast path, got {:?}", + runner.state().waiting_for + ); + }; + options + .iter() + .find(|selection| selection.source.object_id == source) + .cloned() + .expect("the prompt should retain the sacrificial source's exact capability") +} + +fn generic_spell(scenario: &mut GameScenario) -> ObjectId { + scenario + .add_spell_to_hand(P0, "Sacrificial Mana Payment Witness", true) + .with_mana_cost(ManaCost::generic(1)) + .id() +} + +/// A self-sacrificing mana ability is offered only after the real cast pipeline +/// has exhausted non-sacrificial payment rows. +#[test] +fn self_sacrificing_mana_source_pays_a_production_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Blood Pet Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the offered self-sacrificing source should activate during payment"); + + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Black), + 1, + "the activated source's mana reaches the pending spell payment" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + + runner + .act(GameAction::PassPriority) + .expect("the selected mana should pay the spell through the ordinary payment reducer"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// A frozen source selection must preserve an AnyOneColor activation's normal +/// color prompt, then resume and finish the original cast. +#[test] +fn any_one_color_self_sacrifice_selection_completes_production_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Gold Witness", 1, 1) + .with_ability_definition(any_one_color_sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the frozen self-sacrifice selection should enter the color prompt"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + )); + + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 1, + }) + .expect("choosing the source's color should resume the pending payment"); + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + + runner + .act(GameAction::PassPriority) + .expect("the chosen mana should finish the original cast"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// A non-sacrificial row on the same permanent remains available to the +/// automatic planner; only the irreversible row is held for explicit consent. +#[test] +fn automatic_payment_keeps_a_non_sacrificial_row_on_a_sacrificial_source() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Two-Row Mana Witness", 1, 1) + .as_artifact() + .with_ability_definition(sacrificial_mana_ability(AbilityCost::Tap, ManaColor::Red)) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert!(runner.state().objects[&source].tapped); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// The pre-activation prompt does not bypass a mana ability's own sacrifice +/// choice; selecting another artifact pays that cost while its source remains +/// on the battlefield. +#[test] +fn sacrifice_another_permanent_mana_source_resumes_the_pending_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Krark-Clan Ironworks Witness", 1, 1) + .as_artifact() + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + 1, + )), + ManaColor::Red, + )) + .id(); + let sacrifice = scenario + .add_creature(P0, "Sacrificial Artifact Witness", 1, 1) + .as_artifact() + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the offered source should enter its normal interactive cost payment"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + ref choices, + .. + } if choices.contains(&sacrifice) + )); + + runner + .act(GameAction::SelectCards { + cards: vec![sacrifice], + }) + .expect("the source's ordinary sacrifice-cost reducer should accept another artifact"); + + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&sacrifice].zone, Zone::Graveyard); + assert_eq!( + runner.state().players[P0.0 as usize].mana_pool.total(), + 1, + "the selected ability's mana remains available to the original spell" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); +} + +/// Returning from the safety prompt keeps the pending spell but neither spends +/// mana nor performs the irreversible source activation. +#[test] +fn back_from_sacrificial_mana_prompt_preserves_the_cast_without_cancellation() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Back Button Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + assert!( + !candidate_actions(runner.state()) + .iter() + .any(|candidate| matches!(candidate.action, GameAction::CancelCast)), + "the real safety prompt must not synthesize a cast-cancellation action" + ); + runner + .act(GameAction::BackToManaPayment) + .expect("the prompt's explicit back action should be accepted"); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + assert!(runner.state().pending_cast.is_some()); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); +} + +/// An engine-authored selection is revalidated at the reducer boundary, so a +/// source that became ineligible cannot be activated from a stale payment prompt. +#[test] +fn stale_sacrificial_mana_selection_is_rejected_without_mutating_payment() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Stale Selection Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .state_mut() + .objects + .get_mut(&source) + .unwrap() + .controller = PlayerId(1); + + assert!( + runner + .act(GameAction::ActivateManaSource { selection }) + .is_err(), + "a stale source must fail before the payment reducer mutates state" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaSourceSelection { .. } + )); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&source].controller, PlayerId(1)); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 4651f4588c..8fa9ce7fe8 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2545,7 +2545,7 @@ pub fn convert_available_action( produced_mana: None, }), }), - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { AvailableActionConversion::Available(AvailableAction { id, kind: AvailableActionKind::ActivateAbility(ActivatableAbilityInfo { @@ -2567,9 +2567,10 @@ pub fn convert_available_action( }, }) } - GameAction::PassPriority | GameAction::CancelCast | GameAction::Concede { .. } => { - AvailableActionConversion::Skip - } + GameAction::PassPriority + | GameAction::CancelCast + | GameAction::BackToManaPayment + | GameAction::Concede { .. } => AvailableActionConversion::Skip, GameAction::DeclareAttackers { .. } => AvailableActionConversion::Skip, GameAction::DeclareBlockers { .. } => AvailableActionConversion::Skip, GameAction::ChooseUntap { .. } => { diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 7b9d1b7e5d..82ad9727e2 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -20,7 +20,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { WaitingFor::MulliganDecision { .. } | WaitingFor::OpeningHandBottomCards { .. } => { DecisionKind::Mulligan } - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } => { + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } => { DecisionKind::ManaPayment } WaitingFor::ChooseXValue { .. } => DecisionKind::ChooseX, @@ -58,7 +60,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { GameAction::PlayLand { .. } => DecisionKind::PlayLand, GameAction::CastSpell { .. } => DecisionKind::CastSpell, GameAction::ActivateAbility { .. } => DecisionKind::ActivateAbility, - GameAction::TapLandForMana { .. } | GameAction::UntapLandForMana { .. } => { + GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::UntapLandForMana { .. } => { DecisionKind::ActivateManaAbility } // Default: any other priority-time action (PassPriority, special @@ -338,6 +342,9 @@ mod tests { }, ability_index: None, mana_type: engine::types::mana::ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete( + engine::types::mana::ManaType::Green, + ), atomic_combination: None, restrictions: Vec::new(), penalty: engine::types::mana::ManaSourcePenalty::None, diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs index 0330d1909a..8e409884a7 100644 --- a/crates/phase-ai/src/policies/discard_payoff.rs +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -198,6 +198,8 @@ fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/phase-ai/src/policies/draw_payoff.rs b/crates/phase-ai/src/policies/draw_payoff.rs index 1523e59c75..bd3c2d682a 100644 --- a/crates/phase-ai/src/policies/draw_payoff.rs +++ b/crates/phase-ai/src/policies/draw_payoff.rs @@ -225,6 +225,8 @@ fn candidate_draws_structurally(ctx: &PolicyContext<'_>) -> bool { | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index fc84ed5f34..70bba9440e 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -767,6 +767,19 @@ pub fn emit_trace_for_candidate( /// `config` supplies policy penalties used by selection escapes (e.g. sacrifice /// value ordering); difficulty/search knobs are unused here. pub fn fallback_action(state: &GameState, config: &AiConfig) -> Option { + // CR 605.3b: A sacrificial mana prompt is an explicit payment decision, + // not a generic pending-cast failure. Pick only an engine-issued source or + // the exact BackToManaPayment escape; never synthesize CancelCast here. + if matches!(state.waiting_for, WaitingFor::ManaSourceSelection { .. }) { + return engine::ai_support::legal_actions(state) + .into_iter() + .find(|action| { + matches!( + action, + GameAction::ActivateManaSource { .. } | GameAction::BackToManaPayment + ) + }); + } // CR 601.2c: A spell's target step must use the engine's current legal // target list. `target_slots` is a historical snapshot and can be stale // after earlier selections; if no current legal action remains, abort the @@ -781,7 +794,11 @@ pub fn fallback_action(state: &GameState, config: &AiConfig) -> Option Option Option { // These are all pending-cast states — the has_pending_cast guard - // above already returned CancelCast. This branch is unreachable - // at runtime but keeps the match exhaustive. + // above already returned CancelCast. ManaSourceSelection is + // intercepted above and never synthesizes CancelCast. This branch + // is unreachable at runtime but keeps the match exhaustive. Some(GameAction::CancelCast) } } diff --git a/crates/server-core/src/client_message_wire_guard.rs b/crates/server-core/src/client_message_wire_guard.rs index b66fc9f433..3fe9c116f4 100644 --- a/crates/server-core/src/client_message_wire_guard.rs +++ b/crates/server-core/src/client_message_wire_guard.rs @@ -304,6 +304,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: vec![ManaRestriction::OnlyForAny(vec![ ManaRestriction::OnlyForSpell; @@ -328,6 +329,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 29442cfc55..5844a3c693 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -565,7 +565,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { bound_list("SetPhaseStops.stops", stops.len())?; } GameAction::SetPriorityPassingMode { .. } => {} - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { guard_mana_source_selection_payload(selection)?; } GameAction::DistributeAmong { distribution, .. } => { @@ -618,6 +618,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::MulliganDecision { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index ea2b06b7a0..4e9069a6f0 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -627,6 +627,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(7), 3), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, @@ -653,6 +654,22 @@ mod tests { } _ => panic!("wrong variant"), } + + let GameAction::TapLandForMana { selection } = action else { + unreachable!("fixture action is a land-mana selection"); + }; + let generic = GameAction::ActivateManaSource { selection }; + let json = serde_json::to_string(&ClientMessage::Action { + action: generic.clone(), + }) + .unwrap(); + let parsed: ClientMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ClientMessage::Action { + action: restored_action, + } => assert_eq!(restored_action, generic), + _ => panic!("wrong variant"), + } } #[test] diff --git a/crates/server-core/tests/game_action_payload_guard.rs b/crates/server-core/tests/game_action_payload_guard.rs index 5a2c77fc86..9aa6d4bfe6 100644 --- a/crates/server-core/tests/game_action_payload_guard.rs +++ b/crates/server-core/tests/game_action_payload_guard.rs @@ -31,6 +31,7 @@ fn mana_source_selection() -> ManaSourceSelection { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: Some(0), mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None,
{t("manaSourceSelection.description")}