Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion client/src/adapter/generated/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
14 changes: 13 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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[] };
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 } }
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions client/src/components/mana/ManaPaymentUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +627 to +633

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gate the selection dialog to the acting player.

ManaSourceSelectionUI renders actionable controls for every client with this waiting state. Reuse useCanActForWaitingState() and return null when it is false, as the adjacent mana-payment prompt does.

Proposed fix
 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);
+  const canAct = useCanActForWaitingState();

-  if (waitingFor?.type !== "ManaSourceSelection") return null;
+  if (waitingFor?.type !== "ManaSourceSelection" || !canAct) return null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
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);
const canAct = useCanActForWaitingState();
if (waitingFor?.type !== "ManaSourceSelection" || !canAct) return null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/mana/ManaPaymentUI.tsx` around lines 627 - 633, Update
ManaSourceSelectionUI to call useCanActForWaitingState() and return null when it
indicates the client cannot act, matching the adjacent mana-payment prompt while
preserving the existing waitingFor type check and selection UI behavior for the
acting player.


return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-4 sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.section
role="dialog"
aria-modal="true"
aria-label={t("manaSourceSelection.title")}
className="w-full max-w-md rounded-xl bg-slate-900 p-5 shadow-2xl ring-1 ring-amber-400/40"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
>
<h2 className="text-lg font-semibold text-amber-200">{t("manaSourceSelection.title")}</h2>
<p className="mt-1 text-sm text-slate-300">{t("manaSourceSelection.description")}</p>
<div className="mt-4 space-y-2">
{waitingFor.data.options.map((selection, index) => {
const source = gameState?.objects[selection.source.object_id];
return (
<button
key={`${selection.source.object_id}-${selection.ability_index ?? "basic"}-${index}`}
type="button"
className="flex min-h-11 w-full items-center justify-between rounded-lg bg-amber-500/10 px-3 text-left text-sm text-white ring-1 ring-amber-300/30 transition hover:bg-amber-500/20"
onClick={() => dispatch({ type: "ActivateManaSource", data: { selection } })}
>
<span>{source?.name ?? t("manaSourceSelection.unknownSource")}</span>
<span className="text-xs text-amber-200">{t("manaSourceSelection.sacrifice")}</span>
</button>
);
})}
</div>
<button
type="button"
className={gameButtonClass({
tone: "slate",
size: "md",
className: "mt-4 w-full",
})}
onClick={() => dispatch({ type: "BackToManaPayment" })}
>
{t("manaSourceSelection.back")}
</button>
</motion.section>
</motion.div>
</AnimatePresence>
);
}

// Color → shard symbol code for `ManaSymbol` (White→"W", …, Colorless→"C").
const COLOR_SHARD: Record<ManaType, string> = {
White: "W",
Expand Down
62 changes: 61 additions & 1 deletion client/src/components/mana/__tests__/ManaPaymentUI.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(<ManaSourceSelectionUI />);

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" });
});
});
21 changes: 10 additions & 11 deletions client/src/components/settings/PreferencesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
CommandZoneDisplay,
LogDefaultState,
MultiplayerBoardLayout,
SpellPaymentMode,
ZoneCollapseMode,
} from "../../stores/preferencesStore.ts";
import type { SupportedLng } from "../../i18n/resources.ts";
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -428,15 +430,12 @@ export function PreferencesModal({
</SettingGroup>

<SettingGroup label={t("gameplay.spellPayment")}>
<label className="flex min-h-11 items-center gap-2">
<input
type="checkbox"
checked={spellPaymentMode === "manual"}
onChange={(e) => setSpellPaymentMode(e.target.checked ? "manual" : "auto")}
className="accent-cyan-500"
/>
<span className="text-sm text-slate-200">{t("gameplay.manualManaPayment")}</span>
</label>
<SegmentedControl
options={SPELL_PAYMENT_MODES}
value={spellPaymentMode}
onChange={setSpellPaymentMode}
renderLabel={(option) => t(`gameplay.spellPaymentOptions.${option}`)}
/>
</SettingGroup>

{isTauri() && (
Expand Down Expand Up @@ -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")}
</button>
Expand Down Expand Up @@ -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")}
</button>
Expand Down
31 changes: 31 additions & 0 deletions client/src/game/__tests__/castPaymentMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameAction, { type: "CastSpell" }>).data.payment_mode).toEqual({
type: "Manual",
});
});
});
Loading
Loading