From 8b68a3e2e95cddc15c920a94f3cae0a1acab8596 Mon Sep 17 00:00:00 2001 From: Matt Evans Date: Thu, 18 Jun 2026 13:41:27 -0700 Subject: [PATCH 1/2] Use battlefield clicks for board choices --- client/src/adapter/types.ts | 2 +- .../board/BattlefieldZoneOverflow.tsx | 20 ++ .../board/BoardInteractionContext.tsx | 4 + client/src/components/board/GameBoard.tsx | 22 ++ .../src/components/board/GroupedPermanent.tsx | 179 +++++++++- client/src/components/board/PermanentCard.tsx | 146 +++++++- .../BattlefieldZoneOverflow.test.tsx | 12 + .../board/__tests__/GroupedPermanent.test.tsx | 29 ++ .../board/__tests__/PermanentCard.test.tsx | 116 +++++- .../src/components/modal/CardChoiceModal.tsx | 85 +---- client/src/components/modal/DialogHost.tsx | 3 +- .../modal/__tests__/DialogHost.test.tsx | 45 +++ .../components/targeting/TargetingOverlay.tsx | 198 +++++++++-- .../__tests__/TargetingOverlay.test.tsx | 52 +++ client/src/i18n/locales/en/game.json | 55 ++- .../viewmodel/__tests__/gameStateView.test.ts | 176 +++++++++ client/src/viewmodel/gameStateView.ts | 333 ++++++++++++++++++ 17 files changed, 1370 insertions(+), 107 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 6fd88066b5..c0b9959db2 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1233,7 +1233,7 @@ export type WaitingFor = // "unless they X or Y" punisher class. | { type: "UnlessPaymentChooseCost"; data: { player: PlayerId; costs: UnlessCost[]; pending_effect: unknown; trigger_event?: unknown; effect_description?: string } } | { type: "WardDiscardChoice"; data: { player: PlayerId; cards: ObjectId[]; pending_effect: unknown; remaining: number; filter?: unknown } } - | { type: "WardSacrificeChoice"; data: { player: PlayerId; permanents: ObjectId[]; pending_effect: unknown; remaining: number } } + | { type: "WardSacrificeChoice"; data: { player: PlayerId; permanents: ObjectId[]; pending_effect: unknown; remaining: number; min_total_power?: number | null } } | { type: "UnlessBounceChoice"; data: { player: PlayerId; permanents: ObjectId[]; pending_effect: unknown; remaining: number } } | { type: "ChooseRingBearer"; data: { player: PlayerId; candidates: ObjectId[] } } | { type: "RevealUntilKeptChoice"; data: { player: PlayerId; hit_card: ObjectId; source_id: ObjectId; accept_zone: string; decline_zone: string; enter_tapped: boolean; enters_attacking: boolean; revealed_misses: ObjectId[]; rest_destination: string } } diff --git a/client/src/components/board/BattlefieldZoneOverflow.tsx b/client/src/components/board/BattlefieldZoneOverflow.tsx index 29f2edbb6e..244ce91afe 100644 --- a/client/src/components/board/BattlefieldZoneOverflow.tsx +++ b/client/src/components/board/BattlefieldZoneOverflow.tsx @@ -302,9 +302,11 @@ function ZoneSummaryTile({ groups, objectIds, zone, onOpen }: ZoneSummaryTilePro const combatMode = useUiStore((s) => s.combatMode); const { activatableObjectIds, + boardChoiceObjectIds, committedAttackerIds, incomingAttackerCounts, manaTappableObjectIds, + selectableSacrificeObjectIds, validAttackerIds, validTargetObjectIds, } = useBoardInteractionState(); @@ -321,6 +323,8 @@ function ZoneSummaryTile({ groups, objectIds, zone, onOpen }: ZoneSummaryTilePro let attacking = 0; let incoming = 0; let mana = 0; + let boardChoice = 0; + let sacrifice = 0; let selected = 0; let validAttackers = 0; let validTargets = 0; @@ -330,6 +334,8 @@ function ZoneSummaryTile({ groups, objectIds, zone, onOpen }: ZoneSummaryTilePro if (committedAttackerIds.has(id)) attacking++; incoming += incomingAttackerCounts.get(id) ?? 0; if (manaTappableObjectIds.has(id)) mana++; + if (boardChoiceObjectIds.has(id)) boardChoice++; + if (selectableSacrificeObjectIds.has(id)) sacrifice++; if (validAttackerIds.has(id)) validAttackers++; if (validTargetObjectIds.has(id)) validTargets++; if ( @@ -346,18 +352,22 @@ function ZoneSummaryTile({ groups, objectIds, zone, onOpen }: ZoneSummaryTilePro attacking, incoming, mana, + boardChoice, + sacrifice, selected, validAttackers: combatMode === "attackers" ? validAttackers : 0, validTargets, }; }, [ activatableObjectIds, + boardChoiceObjectIds, blockerAssignments, combatMode, committedAttackerIds, incomingAttackerCounts, manaTappableObjectIds, objectIds, + selectableSacrificeObjectIds, selectedAttackers, selectedCardIds, validAttackerIds, @@ -401,6 +411,8 @@ function ZoneSummaryTile({ groups, objectIds, zone, onOpen }: ZoneSummaryTilePro || interaction.attacking > 0 || interaction.incoming > 0 || interaction.mana > 0 + || interaction.boardChoice > 0 + || interaction.sacrifice > 0 || interaction.selected > 0 || interaction.validAttackers > 0 || interaction.validTargets > 0; @@ -494,6 +506,8 @@ interface InteractionSummary { attacking: number; incoming: number; mana: number; + boardChoice: number; + sacrifice: number; selected: number; validAttackers: number; validTargets: number; @@ -505,6 +519,12 @@ function InteractionBadges({ interaction }: { interaction: InteractionSummary }) interaction.validTargets > 0 ? { key: "target", label: t("battlefieldOverflow.badges.target"), tooltip: t("battlefieldOverflow.badgeTooltips.target"), count: interaction.validTargets, className: "bg-lime-300 text-lime-950" } : null, + interaction.sacrifice > 0 + ? { key: "sacrifice", label: t("battlefieldOverflow.badges.sacrifice"), tooltip: t("battlefieldOverflow.badgeTooltips.sacrifice"), count: interaction.sacrifice, className: "bg-red-500 text-white" } + : null, + interaction.boardChoice > interaction.sacrifice + ? { key: "choice", label: t("battlefieldOverflow.badges.choice"), tooltip: t("battlefieldOverflow.badgeTooltips.choice"), count: interaction.boardChoice - interaction.sacrifice, className: "bg-sky-400 text-sky-950" } + : null, interaction.validAttackers > 0 ? { key: "attack", label: t("battlefieldOverflow.badges.attack"), tooltip: t("battlefieldOverflow.badgeTooltips.attack"), count: interaction.validAttackers, className: "bg-orange-500 text-white" } : null, diff --git a/client/src/components/board/BoardInteractionContext.tsx b/client/src/components/board/BoardInteractionContext.tsx index 7ea1cd41a5..4c79c79c18 100644 --- a/client/src/components/board/BoardInteractionContext.tsx +++ b/client/src/components/board/BoardInteractionContext.tsx @@ -2,11 +2,13 @@ import { createContext, useContext } from "react"; interface BoardInteractionState { activatableObjectIds: Set; + boardChoiceObjectIds: Set; committedAttackerIds: Set; /** Per-permanent count of attackers targeting it (Planeswalker / Battle * attack targets). Computed once in GameBoard; each card reads O(1). */ incomingAttackerCounts: ReadonlyMap; manaTappableObjectIds: Set; + selectableSacrificeObjectIds: Set; selectableManaCostCreatureIds: Set; undoableTapObjectIds: Set; validAttackerIds: Set; @@ -18,9 +20,11 @@ const EMPTY_MAP: ReadonlyMap = new Map(); const EMPTY_STATE: BoardInteractionState = { activatableObjectIds: EMPTY_SET, + boardChoiceObjectIds: EMPTY_SET, committedAttackerIds: EMPTY_SET, incomingAttackerCounts: EMPTY_MAP, manaTappableObjectIds: EMPTY_SET, + selectableSacrificeObjectIds: EMPTY_SET, selectableManaCostCreatureIds: EMPTY_SET, undoableTapObjectIds: EMPTY_SET, validAttackerIds: EMPTY_SET, diff --git a/client/src/components/board/GameBoard.tsx b/client/src/components/board/GameBoard.tsx index 01a2eaaaaf..2168dcd8f1 100644 --- a/client/src/components/board/GameBoard.tsx +++ b/client/src/components/board/GameBoard.tsx @@ -9,6 +9,8 @@ import { sortCreaturesForBlockers } from "../../viewmodel/blockerSorting.ts"; import { isManaObjectAction } from "../../viewmodel/cardActionChoice.ts"; import { buildPlayerBattlefieldView, + getBoardChoiceView, + getBattlefieldSacrificeChoice, getWaitingForObjectChoiceIds, getOpponentIds, isOneOnOne, @@ -70,7 +72,9 @@ export const GameBoard = memo(function GameBoard({ oppHud, playerHud }: GameBoar const validTargetObjectIds = new Set(); const validAttackerIds = new Set(); const activatableObjectIds = new Set(); + const boardChoiceObjectIds = new Set(); const manaTappableObjectIds = new Set(); + const selectableSacrificeObjectIds = new Set(); const selectableManaCostCreatureIds = new Set(); const undoableTapObjectIds = new Set(); const committedAttackerIds = new Set(); @@ -119,6 +123,20 @@ export const GameBoard = memo(function GameBoard({ oppHud, playerHud }: GameBoar validTargetObjectIds.add(objectId); } + const sacrificeChoice = getBattlefieldSacrificeChoice(waitingFor); + if (sacrificeChoice && canActForWaitingState) { + for (const objectId of sacrificeChoice.objectIds) { + selectableSacrificeObjectIds.add(objectId); + } + } + + const boardChoice = getBoardChoiceView(waitingFor); + if (boardChoice && canActForWaitingState) { + for (const objectId of boardChoice.objectIds) { + boardChoiceObjectIds.add(objectId); + } + } + if (waitingFor?.type === "EquipTarget") { for (const objectId of waitingFor.data.valid_targets) { validTargetObjectIds.add(objectId); @@ -134,9 +152,11 @@ export const GameBoard = memo(function GameBoard({ oppHud, playerHud }: GameBoar if (!gameState?.objects) { return { activatableObjectIds, + boardChoiceObjectIds, committedAttackerIds, incomingAttackerCounts, manaTappableObjectIds, + selectableSacrificeObjectIds, selectableManaCostCreatureIds, undoableTapObjectIds, validAttackerIds, @@ -186,9 +206,11 @@ export const GameBoard = memo(function GameBoard({ oppHud, playerHud }: GameBoar return { activatableObjectIds, + boardChoiceObjectIds, committedAttackerIds, incomingAttackerCounts, manaTappableObjectIds, + selectableSacrificeObjectIds, selectableManaCostCreatureIds, undoableTapObjectIds, validAttackerIds, diff --git a/client/src/components/board/GroupedPermanent.tsx b/client/src/components/board/GroupedPermanent.tsx index 8f94014544..4349aa98b9 100644 --- a/client/src/components/board/GroupedPermanent.tsx +++ b/client/src/components/board/GroupedPermanent.tsx @@ -5,8 +5,18 @@ import type { ObjectId, WaitingFor } from "../../adapter/types.ts"; import { dispatchAction } from "../../game/dispatch.ts"; import { usePlayerId } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; +import type { GameObject } from "../../adapter/types.ts"; import type { GroupedPermanent as GroupedPermanentType } from "../../viewmodel/battlefieldProps"; -import { getWaitingForObjectChoiceIds } from "../../viewmodel/gameStateView.ts"; +import { + boardChoiceMaxSelection, + boardChoiceSelectedPower, + buildBoardChoiceAction, + canConfirmBoardChoice, + getBoardChoiceView, + getWaitingForObjectChoiceIds, + isBoardChoiceImmediate, + type BoardChoiceView, +} from "../../viewmodel/gameStateView.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { useBoardInteractionState } from "./BoardInteractionContext.tsx"; @@ -24,12 +34,9 @@ interface GroupedPermanentProps { onExpand: () => void; } -type PickerMode = "attackers" | "blockers" | "equip" | "target" | "tap"; - -interface PickerContext { - mode: PickerMode; - eligibleIds: ObjectId[]; -} +type PickerContext = + | { mode: "attackers" | "blockers" | "equip" | "target" | "tap"; eligibleIds: ObjectId[] } + | { mode: "boardChoice"; eligibleIds: ObjectId[]; choice: BoardChoiceView }; function waitingForPlayer(waitingFor: WaitingFor | null | undefined): number | null { switch (waitingFor?.type) { @@ -44,6 +51,15 @@ function waitingForPlayer(waitingFor: WaitingFor | null | undefined): number | n case "TriggerTargetSelection": case "RetargetChoice": case "PayCost": + case "EffectZoneChoice": + case "WardSacrificeChoice": + case "UnlessBounceChoice": + case "ChooseRingBearer": + case "BlightChoice": + case "CrewVehicle": + case "StationTarget": + case "SaddleMount": + case "HarmonizeTapChoice": return waitingFor.data.player; default: return null; @@ -69,6 +85,7 @@ export const GroupedPermanentDisplay = memo(function GroupedPermanentDisplay({ const setGroupSelectedCards = useUiStore((s) => s.setGroupSelectedCards); const waitingFor = useGameStore((s) => s.waitingFor); const { + boardChoiceObjectIds, committedAttackerIds, validAttackerIds, validTargetObjectIds, @@ -87,6 +104,14 @@ export const GroupedPermanentDisplay = memo(function GroupedPermanentDisplay({ if (renderMode !== "collapsed") return null; if (waitingForPlayer(waitingFor) !== playerId) return null; + const boardChoice = getBoardChoiceView(waitingFor); + if (boardChoice) { + const eligibleIds = group.ids.filter((id) => boardChoiceObjectIds.has(id)); + return eligibleIds.length > 0 + ? { mode: "boardChoice", eligibleIds, choice: boardChoice } + : null; + } + if (combatMode === "attackers") { const eligibleIds = group.ids.filter((id) => validAttackerIds.has(id)); return eligibleIds.length > 0 ? { mode: "attackers", eligibleIds } : null; @@ -127,6 +152,7 @@ export const GroupedPermanentDisplay = memo(function GroupedPermanentDisplay({ return null; }, [ blockerAssignments, + boardChoiceObjectIds, combatClickHandler, combatMode, group.ids, @@ -363,6 +389,7 @@ function CollapsedGroupPicker({ onClose, }: CollapsedGroupPickerProps) { const { t } = useTranslation("game"); + const objects = useGameStore((s) => s.gameState?.objects); const selectedAttackerCount = context.eligibleIds.filter((id) => selectedAttackers.includes(id)).length; const selectedTapCount = context.eligibleIds.filter((id) => selectedCardIds.includes(id)).length; @@ -409,6 +436,17 @@ function CollapsedGroupPicker({ onChange={selectTapCount} /> )} + {context.mode === "boardChoice" && ( + + )} {context.mode === "blockers" && ( | undefined; + selectedCardIds: ObjectId[]; + setGroupSelectedCards: (groupIds: ObjectId[], selectedIds: ObjectId[]) => void; + onClose: () => void; +} + +function BoardChoiceGroupControls({ + choice, + eligibleIds, + groupIds, + objects, + selectedCardIds, + setGroupSelectedCards, + onClose, +}: BoardChoiceGroupControlsProps) { + const { t } = useTranslation("game"); + const selectedForChoice = selectedCardIds.filter((id) => choice.objectIds.includes(id)); + const selectedInGroup = eligibleIds.filter((id) => selectedCardIds.includes(id)); + const maxSelection = boardChoiceMaxSelection(choice); + + const toggleId = (id: ObjectId) => { + const selected = new Set(selectedInGroup); + if (selected.has(id)) { + selected.delete(id); + } else if (maxSelection == null || selectedForChoice.length < maxSelection) { + selected.add(id); + } + setGroupSelectedCards(groupIds, eligibleIds.filter((eligibleId) => selected.has(eligibleId))); + }; + + if (isBoardChoiceImmediate(choice)) { + return ( + { + dispatchAction(buildBoardChoiceAction(choice, [id])); + onClose(); + }} + /> + ); + } + + const canConfirm = canConfirmBoardChoice(choice, selectedForChoice, objects); + const power = + choice.selection.type === "totalPowerAtLeast" + ? boardChoiceSelectedPower(choice, selectedForChoice, objects) + : null; + + return ( +
+ +
+ {power == null + ? t("boardChoice.groupCount", { + selected: selectedForChoice.length, + count: maxSelection ?? eligibleIds.length, + }) + : t("boardChoice.groupPower", { + selected: power, + required: choice.selection.power, + })} +
+ +
+ ); +} + interface CountPickerControlsProps { count: number; max: number; @@ -516,3 +641,43 @@ function ObjectChoiceList({ eligibleIds, onChoose }: ObjectChoiceListProps) { ); } + +interface ObjectToggleListProps { + eligibleIds: ObjectId[]; + objects: Record | undefined; + selectedIds: ObjectId[]; + showPower: boolean; + onToggle: (id: ObjectId) => void; +} + +function ObjectToggleList({ + eligibleIds, + objects, + selectedIds, + showPower, + onToggle, +}: ObjectToggleListProps) { + return ( +
+ {eligibleIds.map((id, index) => { + const selected = selectedIds.includes(id); + const power = Math.max(objects?.[id]?.power ?? 0, 0); + return ( + + ); + })} +
+ ); +} diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index c6d4d6e245..e0a312270a 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -23,6 +23,14 @@ import { COUNTER_COLORS, computePTDisplay, formatCounterTooltip, formatCounterTy import { getCardDisplayColors } from "../card/cardFrame.ts"; import { useBoardInteractionState } from "./BoardInteractionContext.tsx"; import { KeywordStrip } from "./KeywordStrip.tsx"; +import { + boardChoiceMaxSelection, + buildBoardChoiceAction, + getBattlefieldSacrificeChoice, + getBoardChoiceView, + isBoardChoiceImmediate, + type BoardChoiceIntent, +} from "../../viewmodel/gameStateView.ts"; import { collectObjectActions, isManaObjectAction, @@ -107,6 +115,63 @@ function objectIdFromRelatedTarget(target: EventTarget | null): number | null { return Number.isFinite(objectId) ? objectId : null; } +function selectedBoardChoiceGlowClass(intent: BoardChoiceIntent): string { + switch (intent) { + case "sacrifice": + return "ring-2 ring-red-400 shadow-[0_0_14px_4px_rgba(248,113,113,0.55)]"; + case "tap": + return "ring-2 ring-emerald-400 shadow-[0_0_14px_4px_rgba(52,211,153,0.55)]"; + case "blight": + return "ring-2 ring-purple-400 shadow-[0_0_14px_4px_rgba(192,132,252,0.55)]"; + case "ringBearer": + return "ring-2 ring-amber-300 shadow-[0_0_14px_4px_rgba(252,211,77,0.55)]"; + case "return": + case "exile": + case "crew": + case "saddle": + case "station": + return "ring-2 ring-sky-300 shadow-[0_0_14px_4px_rgba(125,211,252,0.55)]"; + } +} + +function availableBoardChoiceGlowClass(intent: BoardChoiceIntent): string { + switch (intent) { + case "sacrifice": + return "ring-2 ring-red-300/80 shadow-[0_0_10px_3px_rgba(248,113,113,0.35)]"; + case "tap": + return "ring-2 ring-emerald-300/70 shadow-[0_0_10px_3px_rgba(74,222,128,0.35)]"; + case "blight": + return "ring-2 ring-purple-300/80 shadow-[0_0_10px_3px_rgba(216,180,254,0.35)]"; + case "ringBearer": + return "ring-2 ring-amber-300/80 shadow-[0_0_10px_3px_rgba(252,211,77,0.35)]"; + case "return": + case "exile": + case "crew": + case "saddle": + case "station": + return "ring-2 ring-sky-300/80 shadow-[0_0_10px_3px_rgba(125,211,252,0.35)]"; + } +} + +function boardChoiceBadgeClass(intent: BoardChoiceIntent): string { + switch (intent) { + case "sacrifice": + return "bg-red-500 text-white"; + case "tap": + return "bg-emerald-500 text-emerald-950"; + case "blight": + return "bg-purple-500 text-white"; + case "ringBearer": + return "bg-amber-400 text-amber-950"; + case "return": + case "exile": + case "crew": + case "saddle": + case "station": + return "bg-sky-400 text-sky-950"; + } +} + export const PermanentCard = memo(function PermanentCard({ objectId, attachmentsLiftedByAncestor = false, onPrimaryClickOverride, coveredIds }: PermanentCardProps) { const { t } = useTranslation("game"); const isMobile = useIsMobile(); @@ -153,9 +218,11 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments ); const { activatableObjectIds, + boardChoiceObjectIds, committedAttackerIds, incomingAttackerCounts, manaTappableObjectIds, + selectableSacrificeObjectIds, selectableManaCostCreatureIds, undoableTapObjectIds, validAttackerIds, @@ -203,6 +270,11 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments ? s.waitingFor.data : null, ); + const waitingFor = useGameStore((s) => s.waitingFor); + const boardChoice = useMemo(() => { + const choice = getBoardChoiceView(waitingFor); + return choice?.player === playerId ? choice : null; + }, [playerId, waitingFor]); const equipTargetChoice = useGameStore((s) => s.waitingFor?.type === "EquipTarget" && s.waitingFor.data.player === playerId ? s.waitingFor.data @@ -210,6 +282,27 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments ); const isSelectableForManaCost = selectableManaCostCreatureIds.has(objectId); const isSelectedForManaCost = isSelectableForManaCost && selectedCardIds.includes(objectId); + const sacrificeChoice = useMemo(() => { + const choice = getBattlefieldSacrificeChoice(waitingFor); + if (!choice) return null; + switch (waitingFor?.type) { + case "EffectZoneChoice": + case "WardSacrificeChoice": + return waitingFor.data.player === playerId ? choice : null; + default: + return null; + } + }, [playerId, waitingFor]); + const isSelectableForSacrifice = selectableSacrificeObjectIds.has(objectId); + const isSelectedForSacrifice = isSelectableForSacrifice && selectedCardIds.includes(objectId); + const isSelectableForBoardChoice = boardChoiceObjectIds.has(objectId) && boardChoice != null; + const isSelectedForBoardChoice = isSelectableForBoardChoice && selectedCardIds.includes(objectId); + const selectedBoardChoiceIds = boardChoice + ? selectedCardIds.filter((id) => boardChoice.objectIds.includes(id)) + : []; + const selectedSacrificeIds = sacrificeChoice + ? selectedCardIds.filter((id) => sacrificeChoice.objectIds.includes(id)) + : []; const setPendingAbilityChoice = useUiStore((s) => s.setPendingAbilityChoice); const cardRef = useRef(null); @@ -300,6 +393,16 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments } else if (isUnderAttack) { glowClass = "ring-2 ring-red-500 shadow-[0_0_14px_4px_rgba(220,38,38,0.55)]"; + } else if (isSelectedForBoardChoice && boardChoice) { + glowClass = selectedBoardChoiceGlowClass(boardChoice.intent); + } else if (isSelectableForBoardChoice && boardChoice) { + glowClass = availableBoardChoiceGlowClass(boardChoice.intent); + } else if (isSelectedForSacrifice) { + glowClass = + "ring-2 ring-red-400 shadow-[0_0_14px_4px_rgba(248,113,113,0.55)]"; + } else if (isSelectableForSacrifice) { + glowClass = + "ring-2 ring-red-300/80 shadow-[0_0_10px_3px_rgba(248,113,113,0.35)]"; } else if (isSelectedForManaCost) { glowClass = "ring-2 ring-emerald-400 shadow-[0_0_14px_4px_rgba(52,211,153,0.55)]"; @@ -376,7 +479,32 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments if (obj.attached_to !== null) e.stopPropagation(); // A PayCost TapCreatures prompt is mid-cost resolution — check before combat // mode so clicks land even when DeclareAttackers combat mode is active. - if (isSelectableForManaCost && tapCreatureCostChoice) { + if (isSelectableForBoardChoice && boardChoice) { + if (isBoardChoiceImmediate(boardChoice)) { + dispatchAction(buildBoardChoiceAction(boardChoice, [objectId])); + } else { + const maxSelection = boardChoiceMaxSelection(boardChoice); + if ( + isSelectedForBoardChoice + || maxSelection == null + || selectedBoardChoiceIds.length < maxSelection + ) { + toggleSelectedCard(objectId); + } + } + } else if (isSelectableForSacrifice && sacrificeChoice) { + if (!sacrificeChoice.upTo && sacrificeChoice.count === 1) { + dispatchAction({ + type: "SelectCards", + data: { cards: [objectId] }, + }); + } else if ( + isSelectedForSacrifice + || selectedSacrificeIds.length < sacrificeChoice.count + ) { + toggleSelectedCard(objectId); + } + } else if (isSelectableForManaCost && tapCreatureCostChoice) { if ( isSelectedForManaCost || selectedCardIds.length < tapCreatureCostChoice.count @@ -671,6 +799,22 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments )} + {isSelectableForBoardChoice && boardChoice && ( +
+ {t(`permanent.boardChoiceBadges.${boardChoice.intent}`)} +
+ )} + + {!isSelectableForBoardChoice && isSelectableForSacrifice && ( +
+ {t("permanent.sacrifice")} +
+ )} + {/* CR 707.2: "Copy" badge for token-copies of real cards — these are pixel-identical to the printed permanent, so without this tag there's no way to tell a copy apart from the original on the board. Hidden diff --git a/client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx b/client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx index b31b32ac30..b7af3722f7 100644 --- a/client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx +++ b/client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx @@ -110,6 +110,8 @@ function renderOverflow(options: { groups?: GroupedPermanentType[]; includePreview?: boolean; objects?: Record; + boardChoiceObjectIds?: Set; + selectableSacrificeObjectIds?: Set; validTargetObjectIds?: Set; committedAttackerIds?: Set; zone?: "lands" | "support" | "creatures"; @@ -123,9 +125,11 @@ function renderOverflow(options: { { expect(screen.getByText(/mana 1/i)).toBeInTheDocument(); }); + it("surfaces hidden board-choice permanents as interactive", () => { + renderOverflow({ boardChoiceObjectIds: new Set([2]) }); + + const summary = screen.getByRole("button", { name: /open lands drawer/i }); + expect(screen.getByText(/pick 1/i)).toBeInTheDocument(); + expect(summary.className).toContain("border-cyan-300"); + }); + it("uses the shared battlefield hover preview inside the drawer", () => { renderOverflow({ includePreview: true }); diff --git a/client/src/components/board/__tests__/GroupedPermanent.test.tsx b/client/src/components/board/__tests__/GroupedPermanent.test.tsx index 18e593f153..fe1367be1d 100644 --- a/client/src/components/board/__tests__/GroupedPermanent.test.tsx +++ b/client/src/components/board/__tests__/GroupedPermanent.test.tsx @@ -86,6 +86,7 @@ function makeGroup(): GroupedPermanentType { } function renderGroup(options: { + boardChoiceObjectIds?: Set; validAttackerIds?: Set; validTargetObjectIds?: Set; committedAttackerIds?: Set; @@ -94,9 +95,11 @@ function renderGroup(options: { { }); }); + it("dispatches an immediate board choice from a collapsed group picker", () => { + const waitingFor: WaitingFor = { + type: "StationTarget", + data: { + player: 0, + spacecraft_id: 42, + eligible_creatures: [1, 2, 3], + }, + }; + useGameStore.setState({ + gameState: makeState(waitingFor), + waitingFor, + }); + renderGroup({ boardChoiceObjectIds: new Set([1, 2, 3]) }); + + fireEvent.click(screen.getByRole("button", { name: "Choose Saproling token" })); + fireEvent.click(screen.getByRole("button", { name: "#2" })); + + expect(dispatchAction).toHaveBeenCalledWith({ + type: "ActivateStation", + data: { spacecraft_id: 42, creature_id: 2 }, + }); + }); + it("auto-expands committed attackers during blocker declaration", () => { const waitingFor: WaitingFor = { type: "DeclareBlockers", diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index fcffcfd425..a4b5d22853 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -118,14 +118,20 @@ function makeState(): GameState { } as unknown as GameState; } -function renderPermanent(validTargetObjectIds = new Set()) { +function renderPermanent( + validTargetObjectIds = new Set(), + selectableSacrificeObjectIds = new Set(), + boardChoiceObjectIds = new Set(), +) { return render( { }); }); + it("submits a single battlefield sacrifice choice from the board", () => { + const gameState = { + ...makeState(), + waiting_for: { + type: "EffectZoneChoice", + data: { + player: 0, + cards: [1], + count: 1, + source_id: 99, + effect_kind: "Sacrifice", + zone: "Battlefield", + destination: null, + }, + }, + } as unknown as GameState; + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + }); + const { container } = renderPermanent(new Set(), new Set([1])); + const permanent = container.querySelector('[data-object-id="1"]') as HTMLElement; + + fireEvent.click(permanent); + + expect(dispatchAction).toHaveBeenCalledWith({ + type: "SelectCards", + data: { cards: [1] }, + }); + }); + + it("submits immediate board choices from the board", () => { + const gameState = { + ...makeState(), + waiting_for: { + type: "StationTarget", + data: { + player: 0, + spacecraft_id: 9, + eligible_creatures: [1], + }, + }, + } as unknown as GameState; + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + }); + const { container } = renderPermanent(new Set(), new Set(), new Set([1])); + const permanent = container.querySelector('[data-object-id="1"]') as HTMLElement; + + fireEvent.click(permanent); + + expect(dispatchAction).toHaveBeenCalledWith({ + type: "ActivateStation", + data: { spacecraft_id: 9, creature_id: 1 }, + }); + }); + + it("counts only active board-choice selections when enforcing count limits", () => { + const gameState = { + ...makeState(), + waiting_for: { + type: "PayCost", + data: { + player: 0, + kind: { type: "ReturnToHand" }, + choices: [1], + count: 1, + min_count: 1, + resume: { + type: "Spell", + Spell: { + object_id: 9, + card_id: 90, + ability: { targets: [] }, + cost: { type: "NoCost" }, + }, + }, + }, + }, + } as unknown as GameState; + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + }); + useUiStore.setState({ selectedCardIds: [99] }); + const { container } = renderPermanent(new Set(), new Set(), new Set([1])); + const permanent = container.querySelector('[data-object-id="1"]') as HTMLElement; + + fireEvent.click(permanent); + + expect(useUiStore.getState().selectedCardIds).toEqual([99, 1]); + }); + it("renders action affordance highlights above the card face", () => { const { container } = renderPermanent(new Set([1])); const highlight = container.querySelector( @@ -329,9 +429,11 @@ describe("PermanentCard attachments", () => { { { { { { { ; case "EffectZoneChoice": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "DrawnThisTurnTopdeckChoice": if (!canActForWaitingState) return null; @@ -182,6 +184,7 @@ export function CardChoiceModal() { return ; case "PayCost": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "MultiTargetSelection": if (!canActForWaitingState) return null; @@ -200,21 +203,26 @@ export function CardChoiceModal() { return null; case "BlightChoice": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "CrewVehicle": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "StationTarget": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "SaddleMount": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "CollectEvidenceChoice": if (!canActForWaitingState) return null; return ; case "HarmonizeTapChoice": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "PairChoice": if (!canActForWaitingState) return null; @@ -267,9 +275,10 @@ export function CardChoiceModal() { ); case "WardSacrificeChoice": if (!canActForWaitingState) return null; - return ; + return null; case "UnlessBounceChoice": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "AssignCombatDamage": if (!canActForWaitingState) return null; @@ -323,6 +332,7 @@ export function CardChoiceModal() { return ; case "ChooseRingBearer": if (!canActForWaitingState) return null; + if (getBoardChoiceView(waitingFor)) return null; return ; case "LearnChoice": if (!canActForWaitingState) return null; @@ -2394,79 +2404,6 @@ function SaddleModal({ data }: { data: SaddleMount["data"] }) { ); } -// ── Ward Sacrifice Modal ───────────────────────────────────────────────────── - -type WardSacrificeChoice = Extract; - -function WardSacrificeModal({ data }: { data: WardSacrificeChoice["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState(null); - - const handleConfirm = useCallback(() => { - if (selected == null) return; - dispatch({ - type: "SelectCards", - data: { cards: [selected] }, - }); - }, [dispatch, selected]); - - if (!objects) return null; - - return ( - - } - > - - {data.permanents.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected === id; - return ( - setSelected(isSelected ? null : id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - {t("cardChoice.badges.sacrifice")} - -
- )} -
- ); - })} -
-
- ); -} - // ── Unless Bounce Modal ───────────────────────────────────────────────────── type UnlessBounceChoice = Extract; diff --git a/client/src/components/modal/DialogHost.tsx b/client/src/components/modal/DialogHost.tsx index 58319309ef..08328007d9 100644 --- a/client/src/components/modal/DialogHost.tsx +++ b/client/src/components/modal/DialogHost.tsx @@ -7,6 +7,7 @@ import type { WaitingFor } from "../../adapter/types.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; +import { getBoardChoiceView } from "../../viewmodel/gameStateView.ts"; import { DialogPeekCtx, type DialogPeekContext } from "./dialogPeekContext.ts"; // `WaitingFor` variants that do NOT render a centered dialog/overlay. @@ -61,7 +62,7 @@ export function isClickThroughWaitingFor( ): boolean { if (!waitingFor) return false; if (CLICK_THROUGH_WAITING_FOR_TYPES.has(waitingFor.type)) return true; - return waitingFor.type === "PayCost" && waitingFor.data.kind.type === "TapCreatures"; + return getBoardChoiceView(waitingFor) != null; } function isDialogVisibleFor(waitingFor: WaitingFor | null | undefined): boolean { diff --git a/client/src/components/modal/__tests__/DialogHost.test.tsx b/client/src/components/modal/__tests__/DialogHost.test.tsx index 8406fcec66..ccd52448aa 100644 --- a/client/src/components/modal/__tests__/DialogHost.test.tsx +++ b/client/src/components/modal/__tests__/DialogHost.test.tsx @@ -154,6 +154,51 @@ describe("DialogHost", () => { expect(wrapper?.className ?? "").toMatch(/fixed/); }); + it("anchors battlefield sacrifice choices but leaves them click-through", () => { + setWaitingFor({ + type: "EffectZoneChoice", + data: { + player: 0, + cards: [1], + count: 1, + source_id: 99, + effect_kind: "Sacrifice", + zone: "Battlefield", + destination: null, + }, + } as never); + const { container } = render( + +
+ , + ); + const wrapper = container.firstElementChild as HTMLElement | null; + expect(wrapper?.className ?? "").toMatch(/fixed/); + expect(wrapper?.className ?? "").toMatch(/z-40/); + expect(wrapper?.style.pointerEvents).toBe("none"); + }); + + it("anchors ward sacrifice choices but leaves them click-through", () => { + setWaitingFor({ + type: "WardSacrificeChoice", + data: { + player: 0, + permanents: [1], + pending_effect: {}, + remaining: 1, + }, + } as never); + const { container } = render( + +
+ , + ); + const wrapper = container.firstElementChild as HTMLElement | null; + expect(wrapper?.className ?? "").toMatch(/fixed/); + expect(wrapper?.className ?? "").toMatch(/z-40/); + expect(wrapper?.style.pointerEvents).toBe("none"); + }); + it("resets peek to false when WaitingFor changes (regression)", () => { setWaitingFor({ type: "ModeChoice", data: { player: 0 } } as never); render( diff --git a/client/src/components/targeting/TargetingOverlay.tsx b/client/src/components/targeting/TargetingOverlay.tsx index 3882f710d9..477971b684 100644 --- a/client/src/components/targeting/TargetingOverlay.tsx +++ b/client/src/components/targeting/TargetingOverlay.tsx @@ -6,6 +6,14 @@ import type { TFunction } from "i18next"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; +import { + boardChoiceSelectedPower, + buildBoardChoiceAction, + canConfirmBoardChoice, + getBoardChoiceView, + isBoardChoiceImmediate, + type BoardChoiceView, +} from "../../viewmodel/gameStateView.ts"; import { renderDescription } from "../../utils/description.ts"; import type { GameObject } from "../../adapter/types.ts"; import { RichLabel } from "../mana/RichLabel.tsx"; @@ -41,6 +49,11 @@ export function TargetingOverlay() { : undefined; const isTapCreatureChoice = waitingFor?.type === "PayCost" && waitingFor.data.kind.type === "TapCreatures"; + const boardChoice = getBoardChoiceView(waitingFor); + const isBoardChoice = boardChoice != null; + const selectedBoardChoiceIds = boardChoice + ? selectedCardIds.filter((id) => boardChoice.objectIds.includes(id)) + : []; const targetSlots = isTargetSelection ? waitingFor.data.target_slots : []; const selection = isTargetSelection ? waitingFor.data.selection : null; const currentTargetSlot = isCopyRetarget @@ -48,21 +61,19 @@ export function TargetingOverlay() { : (selection?.current_slot ?? 0); const activeSlot = targetSlots[currentTargetSlot]; const isOptionalCurrentSlot = activeSlot?.optional === true; - const sourceId = waitingFor?.type === "TriggerTargetSelection" - ? waitingFor.data.source_id - : waitingFor?.type === "TargetSelection" - ? waitingFor.data.pending_cast?.object_id - : waitingFor?.type === "ExploreChoice" - ? waitingFor.data.source_id - : waitingFor?.type === "PopulateChoice" - ? waitingFor.data.source_id - : waitingFor?.type === "ReturnAsAuraTarget" - ? waitingFor.data.source_id - : waitingFor?.type === "PayCost" && waitingFor.data.kind.type === "TapCreatures" - ? waitingFor.data.resume.type === "ManaAbility" - ? (waitingFor.data.resume.ManaAbility as { source_id?: number } | undefined)?.source_id - : (waitingFor.data.resume.Spell as { object_id?: number } | undefined)?.object_id - : undefined; + const sourceId = boardChoice?.sourceId ?? ( + waitingFor?.type === "TriggerTargetSelection" + ? waitingFor.data.source_id + : waitingFor?.type === "TargetSelection" + ? waitingFor.data.pending_cast?.object_id + : waitingFor?.type === "ExploreChoice" + ? waitingFor.data.source_id + : waitingFor?.type === "PopulateChoice" + ? waitingFor.data.source_id + : waitingFor?.type === "ReturnAsAuraTarget" + ? waitingFor.data.source_id + : undefined + ); const sourceName = sourceId != null ? objects?.[sourceId]?.name : undefined; const inferredPrompt = buildInferredTargetPrompt({ @@ -94,16 +105,31 @@ export function TargetingOverlay() { dispatch({ type: "SelectCards", data: { cards: selectedCardIds } }); }, [dispatch, selectedCardIds]); + const handleConfirmBoardChoice = useCallback(() => { + if (!boardChoice) return; + dispatch(buildBoardChoiceAction(boardChoice, selectedBoardChoiceIds)); + }, [boardChoice, dispatch, selectedBoardChoiceIds]); + + const handleSkipBoardChoice = useCallback(() => { + if (!boardChoice?.skipAction) return; + dispatch(boardChoice.skipAction); + }, [boardChoice, dispatch]); + + const handleCancelBoardChoice = useCallback(() => { + if (!boardChoice?.cancelAction) return; + dispatch(boardChoice.cancelAction); + }, [boardChoice, dispatch]); + useEffect(() => { - if (!isTapCreatureChoice) { + if (!isBoardChoice) { clearSelectedCards(); return; } clearSelectedCards(); return () => clearSelectedCards(); - }, [clearSelectedCards, isTapCreatureChoice]); + }, [clearSelectedCards, isBoardChoice, waitingFor]); - if (!isTargetSelection && !isCopyTargetChoice && !isCopyRetarget && !isExploreChoice && !isPopulateChoice && !isReturnAsAuraTarget && !isRetargetChoice && !isTapCreatureChoice) return null; + if (!isTargetSelection && !isCopyTargetChoice && !isCopyRetarget && !isExploreChoice && !isPopulateChoice && !isReturnAsAuraTarget && !isRetargetChoice && !isTapCreatureChoice && !isBoardChoice) return null; // Only show targeting UI for the human player if (!canActForWaitingState) return null; @@ -153,6 +179,8 @@ export function TargetingOverlay() { ? (retargetSpellName ? t("targeting.chooseNewTargetForSpell", { spell: retargetSpellName }) : t("targeting.chooseNewTarget")) + : boardChoice + ? boardChoicePrompt(boardChoice, selectedBoardChoiceIds, objects, t) : isTapCreatureChoice ? t("targeting.tapUntappedCreatures", { count: waitingFor.data.count }) : inferredPrompt ?? ( @@ -171,8 +199,9 @@ export function TargetingOverlay() { {/* Player targets are handled by PlayerHud/OpponentHud glow + click */}
- {(waitingFor.type === "TargetSelection" || - (waitingFor.type === "PayCost" && + {(waitingFor?.type === "TargetSelection" || + (!boardChoice && + waitingFor?.type === "PayCost" && waitingFor.data.kind.type === "TapCreatures" && waitingFor.data.resume.type === "Spell")) && ( + )} + {boardChoice && !isBoardChoiceImmediate(boardChoice) && ( + + )} + {boardChoice?.skipAction && ( + + )} {canKeepCurrentTargets && (
)} - {!isSelectableForBoardChoice && isSelectableForSacrifice && ( -
- {t("permanent.sacrifice")} -
- )} - {/* CR 707.2: "Copy" badge for token-copies of real cards — these are pixel-identical to the printed permanent, so without this tag there's no way to tell a copy apart from the original on the board. Hidden diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index a4b5d22853..f64ab1bc04 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -263,7 +263,7 @@ describe("PermanentCard attachments", () => { gameState, waitingFor: gameState.waiting_for, }); - const { container } = renderPermanent(new Set(), new Set([1])); + const { container } = renderPermanent(new Set(), new Set(), new Set([1])); const permanent = container.querySelector('[data-object-id="1"]') as HTMLElement; fireEvent.click(permanent); diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index da26161461..2ed2f49de4 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -88,12 +88,10 @@ type MultiTargetSelection = Extract< { type: "MultiTargetSelection" } >; type PayManaAbilityMana = Extract; -type BlightChoice = Extract; type CollectEvidenceChoice = Extract< WaitingFor, { type: "CollectEvidenceChoice" } >; -type HarmonizeTapChoice = Extract; type PairChoice = Extract; type ChooseLegend = Extract; type CommanderZoneChoice = Extract; @@ -103,11 +101,7 @@ type RevealUntilKeptChoice = Extract< >; type RepeatDecision = Extract; type ManifestDreadChoice = Extract; -type CrewVehicle = Extract; -type StationTarget = Extract; -type SaddleMount = Extract; type DamageSourceChoice = Extract; -type ChooseRingBearer = Extract; type LearnChoice = Extract; /** @@ -202,28 +196,15 @@ export function CardChoiceModal() { // Handled by TargetingOverlay + battlefield clicks (ChooseTarget slot-by-slot). return null; case "BlightChoice": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; case "CrewVehicle": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; case "StationTarget": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; case "SaddleMount": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; + return null; case "CollectEvidenceChoice": if (!canActForWaitingState) return null; return ; case "HarmonizeTapChoice": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; + return null; case "PairChoice": if (!canActForWaitingState) return null; return ; @@ -277,9 +258,7 @@ export function CardChoiceModal() { if (!canActForWaitingState) return null; return null; case "UnlessBounceChoice": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; + return null; case "AssignCombatDamage": if (!canActForWaitingState) return null; return ; @@ -331,9 +310,7 @@ export function CardChoiceModal() { if (!canActForWaitingState) return null; return ; case "ChooseRingBearer": - if (!canActForWaitingState) return null; - if (getBoardChoiceView(waitingFor)) return null; - return ; + return null; case "LearnChoice": if (!canActForWaitingState) return null; return ; @@ -348,73 +325,6 @@ export function CardChoiceModal() { } } -// ── Ring-bearer Modal ────────────────────────────────────────────────────── - -function RingBearerModal({ data }: { data: ChooseRingBearer["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState(null); - - const handleConfirm = useCallback(() => { - if (selected !== null) { - dispatch({ type: "ChooseRingBearer", data: { target: selected } }); - } - }, [dispatch, selected]); - - if (!objects) return null; - - return ( - - } - > - - {data.candidates.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected === id; - return ( - setSelected(id)} - {...hoverProps(id)} - > - - - {isSelected - ? t("cardChoice.badges.selected") - : t("cardChoice.badges.choose")} - - - ); - })} - - - ); -} - // ── Learn Modal ──────────────────────────────────────────────────────────── // CR 701.48a: "Learn" means "You may discard a card. If you do, draw a card. If @@ -2038,445 +1948,6 @@ function PermanentCostModal({ ); } -// ── Blight Modal ───────────────────────────────────────────────────────────── - -function BlightModal({ data }: { data: BlightChoice["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState>(new Set()); - - const toggleSelect = useCallback( - (id: ObjectId) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else if (next.size < data.count) { - next.add(id); - } - return next; - }); - }, - [data.count], - ); - - const handleConfirm = useCallback(() => { - dispatch({ - type: "SelectCards", - data: { cards: Array.from(selected) }, - }); - }, [dispatch, selected]); - - const handleCancel = useCallback(() => { - dispatch({ type: "CancelCast" }); - }, [dispatch]); - - if (!objects) return null; - - const isReady = selected.size === data.count; - - return ( - - - - } - > - - {data.creatures.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected.has(id); - return ( - toggleSelect(id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - -1/-1 - -
- )} -
- ); - })} -
-
- ); -} - -// ── Crew Vehicle Modal ────────────────────────────────────────────────────── - -function CrewModal({ data }: { data: CrewVehicle["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState>(new Set()); - - const toggleSelect = useCallback((id: ObjectId) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const totalPower = Array.from(selected).reduce((sum, id) => { - const obj = objects?.[id]; - return sum + Math.max(obj?.power ?? 0, 0); - }, 0); - - const handleConfirm = useCallback(() => { - dispatch({ - type: "CrewVehicle", - data: { vehicle_id: data.vehicle_id, creature_ids: Array.from(selected) }, - }); - }, [dispatch, data.vehicle_id, selected]); - - if (!objects) return null; - - const isReady = totalPower >= data.crew_power; - - return ( - - } - > - - {data.eligible_creatures.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected.has(id); - return ( - toggleSelect(id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - {t("cardChoice.badges.crew", { power: obj.power ?? 0 })} - -
- )} -
- ); - })} -
-
- ); -} - -// ── Station Target Modal ──────────────────────────────────────────────────── -// CR 702.184a: Pick exactly one untapped creature you control to tap as the -// station ability's cost. Charge counters added = that creature's power. - -function StationTargetModal({ data }: { data: StationTarget["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState(null); - - const handleConfirm = useCallback(() => { - if (selected == null) return; - dispatch({ - type: "ActivateStation", - data: { spacecraft_id: data.spacecraft_id, creature_id: selected }, - }); - }, [dispatch, data.spacecraft_id, selected]); - - if (!objects) return null; - - const selectedPower = - selected != null ? Math.max(objects[selected]?.power ?? 0, 0) : 0; - - return ( - - } - > - - {data.eligible_creatures.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected === id; - return ( - setSelected(id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - {t("cardChoice.badges.station", { - power: Math.max(obj.power ?? 0, 0), - })} - -
- )} -
- ); - })} -
-
- ); -} - -// ── Saddle Mount Modal ────────────────────────────────────────────────────── -// CR 702.171a: Tap any number of other untapped creatures you control with -// total power ≥ N. Mirrors CrewModal's selection + total-power gate. - -function SaddleModal({ data }: { data: SaddleMount["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState>(new Set()); - - const toggleSelect = useCallback((id: ObjectId) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const totalPower = Array.from(selected).reduce((sum, id) => { - const obj = objects?.[id]; - return sum + Math.max(obj?.power ?? 0, 0); - }, 0); - - const handleConfirm = useCallback(() => { - dispatch({ - type: "SaddleMount", - data: { mount_id: data.mount_id, creature_ids: Array.from(selected) }, - }); - }, [dispatch, data.mount_id, selected]); - - if (!objects) return null; - - const isReady = totalPower >= data.saddle_power; - - return ( - - } - > - - {data.eligible_creatures.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected.has(id); - return ( - toggleSelect(id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - {t("cardChoice.badges.saddle", { power: obj.power ?? 0 })} - -
- )} -
- ); - })} -
-
- ); -} - -// ── Unless Bounce Modal ───────────────────────────────────────────────────── - -type UnlessBounceChoice = Extract; - -function UnlessBounceModal({ data }: { data: UnlessBounceChoice["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - const [selected, setSelected] = useState(null); - - const handleConfirm = useCallback(() => { - if (selected == null) return; - dispatch({ - type: "SelectCards", - data: { cards: [selected] }, - }); - }, [dispatch, selected]); - - if (!objects) return null; - - return ( - - } - > - - {data.permanents.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const isSelected = selected === id; - return ( - setSelected(isSelected ? null : id)} - {...hoverProps(id)} - > - - {isSelected && ( -
- - {t("cardChoice.badges.return")} - -
- )} -
- ); - })} -
-
- ); -} - // ── Exile from Graveyard Modal (Escape cost) ──────────────────────────────── // ── Shared exile-for-cost modal (graveyard and hand variants share this) ───── @@ -3020,78 +2491,6 @@ function DiscardModal({ ); } -// ── Harmonize Tap Choice Modal ────────────────────────────────────────────── - -function HarmonizeTapModal({ data }: { data: HarmonizeTapChoice["data"] }) { - const { t } = useTranslation("game"); - const dispatch = useGameDispatch(); - const objects = useGameStore((s) => s.gameState?.objects); - const hoverProps = useInspectHoverProps(); - - const handleTap = useCallback( - (id: ObjectId) => { - dispatch({ type: "HarmonizeTap", data: { creature_id: id } }); - }, - [dispatch], - ); - - const handleSkip = useCallback(() => { - dispatch({ type: "HarmonizeTap", data: { creature_id: null } }); - }, [dispatch]); - - const handleCancel = useCallback(() => { - dispatch({ type: "CancelCast" }); - }, [dispatch]); - - if (!objects) return null; - - return ( - - - - } - > - - {data.eligible_creatures.map((id, index) => { - const obj = objects[id]; - if (!obj) return null; - const power = obj.power ?? 0; - return ( - handleTap(id)} - {...hoverProps(id)} - > - -
- - -{power} generic - -
-
- ); - })} -
-
- ); -} - // ── Legend Choice Modal ───────────────────────────────────────────────────── function LegendChoiceModal({ data }: { data: ChooseLegend["data"] }) { diff --git a/client/src/components/modal/__tests__/DiscardCostModal.test.tsx b/client/src/components/modal/__tests__/DiscardCostModal.test.tsx index 9d2498e9bf..065fd6a836 100644 --- a/client/src/components/modal/__tests__/DiscardCostModal.test.tsx +++ b/client/src/components/modal/__tests__/DiscardCostModal.test.tsx @@ -118,10 +118,10 @@ describe("Discard cost modal", () => { it.each([ [ - "PayCost Sacrifice", + "PayCost ExileFromZone", { player: 0, - kind: { type: "Sacrifice" }, + kind: { type: "ExileFromZone", zone: "Graveyard" }, choices: [], count: 1, min_count: 0, @@ -129,63 +129,84 @@ describe("Discard cost modal", () => { }, ], [ - "PayCost ReturnToHand", + "CollectEvidenceChoice", { player: 0, - kind: { type: "ReturnToHand" }, - choices: [], - count: 1, - min_count: 0, - resume: { type: "Spell", Spell: {} }, + minimum_mana_value: 1, + cards: [], + resume: {}, }, ], + ])("allows cancelling %s", (label, data) => { + // CollectEvidence keeps its own variant `type`; the PayCost-prefixed labels + // all map to the unified `PayCost` variant. + const type = label.startsWith("PayCost") ? "PayCost" : label; + setWaitingFor({ type, data } as unknown as WaitingFor); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(dispatchMock).toHaveBeenCalledWith({ type: "CancelCast" }); + }); + + it.each([ [ - "BlightChoice", + "PayCost Sacrifice", { - player: 0, - count: 1, - creatures: [], - pending_cast: {}, + type: "PayCost", + data: { + player: 0, + kind: { type: "Sacrifice" }, + choices: [], + count: 1, + min_count: 0, + resume: { type: "Spell", Spell: {} }, + }, }, ], [ - "PayCost ExileFromZone", + "PayCost ReturnToHand", { - player: 0, - kind: { type: "ExileFromZone", zone: "Graveyard" }, - choices: [], - count: 1, - min_count: 0, - resume: { type: "Spell", Spell: {} }, + type: "PayCost", + data: { + player: 0, + kind: { type: "ReturnToHand" }, + choices: [], + count: 1, + min_count: 0, + resume: { type: "Spell", Spell: {} }, + }, }, ], [ - "CollectEvidenceChoice", + "BlightChoice", { - player: 0, - minimum_mana_value: 1, - cards: [], - resume: {}, + type: "BlightChoice", + data: { + player: 0, + count: 1, + creatures: [], + pending_cast: {}, + }, }, ], [ "HarmonizeTapChoice", { - player: 0, - eligible_creatures: [], - pending_cast: {}, + type: "HarmonizeTapChoice", + data: { + player: 0, + eligible_creatures: [], + pending_cast: {}, + }, }, ], - ])("allows cancelling %s", (label, data) => { - // BlightChoice/CollectEvidence/Harmonize keep their own variant `type`; - // the PayCost-prefixed labels all map to the unified `PayCost` variant. - const type = label.startsWith("PayCost") ? "PayCost" : label; - setWaitingFor({ type, data } as unknown as WaitingFor); + ])("suppresses the modal for board-native %s", (_label, waitingFor) => { + setWaitingFor(waitingFor as unknown as WaitingFor); render(); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(dispatchMock).toHaveBeenCalledWith({ type: "CancelCast" }); + expect(screen.queryByRole("button")).toBeNull(); }); it("handles discard prompts for mana ability costs", () => { @@ -256,7 +277,7 @@ describe("Discard cost modal", () => { expect(screen.queryByText(/battlefield/i)).not.toBeInTheDocument(); }); - it("describes hand destination without saying battlefield", () => { + it("suppresses battlefield return choices for board-native selection", () => { setWaitingFor( { type: "EffectZoneChoice", @@ -279,18 +300,8 @@ describe("Discard cost modal", () => { render(); - expect(screen.getByText("Return")).toBeInTheDocument(); - expect(screen.getByText("Choose 1 permanent to return to its owner's hand")).toBeInTheDocument(); - expect(screen.queryByText(/battlefield/i)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: /Kor Skyfisher/i })); - expect(screen.getAllByText("Return")).toHaveLength(2); - fireEvent.click(screen.getByRole("button", { name: "Return (1/1)" })); - - expect(dispatchMock).toHaveBeenCalledWith({ - type: "SelectCards", - data: { cards: [10] }, - }); + expect(screen.queryByRole("button")).toBeNull(); + expect(dispatchMock).not.toHaveBeenCalled(); }); it("shows topdeck order and dispatches selected cards in click order", () => { diff --git a/client/src/components/modal/__tests__/ExilePermanentCostModal.test.tsx b/client/src/components/modal/__tests__/ExilePermanentCostModal.test.tsx index cac5f2f415..e2c736f95a 100644 --- a/client/src/components/modal/__tests__/ExilePermanentCostModal.test.tsx +++ b/client/src/components/modal/__tests__/ExilePermanentCostModal.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GameObject, GameState, WaitingFor } from "../../../adapter/types.ts"; @@ -87,12 +87,7 @@ function setWaitingFor(waitingFor: WaitingFor, objects?: Record { +describe("Exile-permanent cost board choice", () => { beforeEach(() => { dispatchMock.mockClear(); useMultiplayerStore.setState({ activePlayerId: 0 }); @@ -102,7 +97,7 @@ describe("Exile-permanent cost modal", () => { cleanup(); }); - it("renders a modal for the ExilePermanent cost kind (no softlock)", () => { + it("suppresses the modal for battlefield ExilePermanent costs", () => { setWaitingFor( { type: "PayCost", @@ -120,105 +115,7 @@ describe("Exile-permanent cost modal", () => { render(); - // Title + subtitle resolve from the new `cardChoice.exilePermanent` keys. - expect(screen.getByText("Exile a permanent")).toBeInTheDocument(); - expect(screen.getByText("Exile 1 permanent you control")).toBeInTheDocument(); - // The eligible permanent is offered for selection. - expect(screen.getByRole("button", { name: /Forest/i })).toBeInTheDocument(); - }); - - it("pluralizes the subtitle for multi-count fixed costs", () => { - setWaitingFor( - { - type: "PayCost", - data: { - player: 0, - kind: { type: "ExilePermanent", filter: null }, - choices: [10, 11], - count: 2, - min_count: 2, - resume: { type: "Spell", Spell: {} }, - }, - } as unknown as WaitingFor, - { 10: makeObject(10, "Forest"), 11: makeObject(11, "Island") }, - ); - - render(); - - expect(screen.getByText("Exile 2 permanents you control")).toBeInTheDocument(); - }); - - it("uses the range subtitle when min_count differs from count", () => { - setWaitingFor( - { - type: "PayCost", - data: { - player: 0, - kind: { type: "ExilePermanent", filter: null }, - choices: [10, 11, 12], - count: 3, - min_count: 1, - resume: { type: "Spell", Spell: {} }, - }, - } as unknown as WaitingFor, - { - 10: makeObject(10, "Forest"), - 11: makeObject(11, "Island"), - 12: makeObject(12, "Mountain"), - }, - ); - - render(); - - expect(screen.getByText("Exile 1 to 3 permanents you control")).toBeInTheDocument(); - }); - - it("dispatches the selected permanent on confirm", () => { - setWaitingFor( - { - type: "PayCost", - data: { - player: 0, - kind: { type: "ExilePermanent", filter: null }, - choices: [10], - count: 1, - min_count: 1, - resume: { type: "Spell", Spell: {} }, - }, - } as unknown as WaitingFor, - { 10: makeObject(10, "Forest") }, - ); - - render(); - - fireEvent.click(screen.getByRole("button", { name: /Forest/i })); - fireEvent.click(screen.getByRole("button", { name: "Exile (1/1)" })); - - expect(dispatchMock).toHaveBeenCalledWith({ - type: "SelectCards", - data: { cards: [10] }, - }); - }); - - it("allows cancelling the cost", () => { - setWaitingFor( - { - type: "PayCost", - data: { - player: 0, - kind: { type: "ExilePermanent", filter: null }, - choices: [10], - count: 1, - min_count: 1, - resume: { type: "Spell", Spell: {} }, - }, - } as unknown as WaitingFor, - { 10: makeObject(10, "Forest") }, - ); - - render(); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - - expect(dispatchMock).toHaveBeenCalledWith({ type: "CancelCast" }); + expect(screen.queryByRole("button")).toBeNull(); + expect(dispatchMock).not.toHaveBeenCalled(); }); }); diff --git a/client/src/components/modal/__tests__/RingBearerModal.test.tsx b/client/src/components/modal/__tests__/RingBearerModal.test.tsx index abb80c90a8..680a5b66e8 100644 --- a/client/src/components/modal/__tests__/RingBearerModal.test.tsx +++ b/client/src/components/modal/__tests__/RingBearerModal.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GameState } from "../../../adapter/types.ts"; @@ -84,7 +84,7 @@ function makeState(): GameState { } as unknown as GameState; } -describe("RingBearerModal (via CardChoiceModal)", () => { +describe("ChooseRingBearer board choice", () => { beforeEach(() => { dispatchMock.mockClear(); const state = makeState(); @@ -100,15 +100,10 @@ describe("RingBearerModal (via CardChoiceModal)", () => { cleanup(); }); - it("dispatches the selected ring-bearer candidate", () => { + it("suppresses the modal for board-native ring-bearer selection", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Samwise Gamgee" })); - fireEvent.click(screen.getByRole("button", { name: "Confirm" })); - - expect(dispatchMock).toHaveBeenCalledWith({ - type: "ChooseRingBearer", - data: { target: 43 }, - }); + expect(screen.queryByRole("button")).toBeNull(); + expect(dispatchMock).not.toHaveBeenCalled(); }); }); diff --git a/client/src/components/targeting/TargetingOverlay.tsx b/client/src/components/targeting/TargetingOverlay.tsx index 477971b684..1508f2feea 100644 --- a/client/src/components/targeting/TargetingOverlay.tsx +++ b/client/src/components/targeting/TargetingOverlay.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -51,9 +51,12 @@ export function TargetingOverlay() { waitingFor?.type === "PayCost" && waitingFor.data.kind.type === "TapCreatures"; const boardChoice = getBoardChoiceView(waitingFor); const isBoardChoice = boardChoice != null; - const selectedBoardChoiceIds = boardChoice - ? selectedCardIds.filter((id) => boardChoice.objectIds.includes(id)) - : []; + const selectedBoardChoiceIds = useMemo( + () => boardChoice + ? selectedCardIds.filter((id) => boardChoice.objectIds.includes(id)) + : [], + [boardChoice, selectedCardIds], + ); const targetSlots = isTargetSelection ? waitingFor.data.target_slots : []; const selection = isTargetSelection ? waitingFor.data.selection : null; const currentTargetSlot = isCopyRetarget diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 203b735c2a..7282201e03 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Kopie", "copyTooltip": "Spielstein-Kopie einer echten Karte", "ringBearer": "Ring", - "ringBearerTooltip": "Ringträger" + "ringBearerTooltip": "Ringträger", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Geg {{seat}}", @@ -515,7 +531,15 @@ "keepCurrentTargets": "Aktuelle Ziele behalten", "skip": "Überspringen", "chooseNewTarget": "Wähle das neue Ziel des Zauberspruchs", - "chooseNewTargetForSpell": "Wähle ein neues Ziel für {{spell}}" + "chooseNewTargetForSpell": "Wähle ein neues Ziel für {{spell}}", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Spielprotokoll", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index d96bc07160..1b89159015 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Copia", "copyTooltip": "Ficha copia de una carta real", "ringBearer": "Anillo", - "ringBearerTooltip": "Portador del Anillo" + "ringBearerTooltip": "Portador del Anillo", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Op {{seat}}", @@ -515,7 +531,15 @@ "keepCurrentTargets": "Conservar objetivos actuales", "skip": "Omitir", "chooseNewTarget": "Elige el nuevo objetivo del hechizo", - "chooseNewTargetForSpell": "Elige un nuevo objetivo para {{spell}}" + "chooseNewTargetForSpell": "Elige un nuevo objetivo para {{spell}}", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Registro de la partida", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 69a3ae6254..81cd402e58 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Copie", "copyTooltip": "Jeton-copie d'une vraie carte", "ringBearer": "Anneau", - "ringBearerTooltip": "Porteur de l'Anneau" + "ringBearerTooltip": "Porteur de l'Anneau", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Adv {{seat}}", @@ -515,7 +531,15 @@ "keepCurrentTargets": "Conserver les cibles actuelles", "skip": "Ignorer", "chooseNewTarget": "Choisissez la nouvelle cible du sort", - "chooseNewTargetForSpell": "Choisissez une nouvelle cible pour {{spell}}" + "chooseNewTargetForSpell": "Choisissez une nouvelle cible pour {{spell}}", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Journal de partie", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 7b77af42e0..6764171c16 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Copia", "copyTooltip": "Copia pedina di una carta reale", "ringBearer": "Anello", - "ringBearerTooltip": "Portatore dell'Anello" + "ringBearerTooltip": "Portatore dell'Anello", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Avv {{seat}}", @@ -515,7 +531,15 @@ "keepCurrentTargets": "Mantieni i bersagli attuali", "skip": "Salta", "chooseNewTarget": "Scegli il nuovo bersaglio della magia", - "chooseNewTargetForSpell": "Scegli un nuovo bersaglio per {{spell}}" + "chooseNewTargetForSpell": "Scegli un nuovo bersaglio per {{spell}}", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Registro di gioco", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index ab6dff152b..aad35f32f5 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Kopia", "copyTooltip": "Kopia tokenowa prawdziwej karty", "ringBearer": "Pierścień", - "ringBearerTooltip": "Powiernik Pierścienia" + "ringBearerTooltip": "Powiernik Pierścienia", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Przec {{seat}}", @@ -515,7 +531,15 @@ "nounTarget": "cel", "confirmTap": "Potwierdź obrócenie ({{selected}}/{{count}})", "keepCurrentTargets": "Zachowaj bieżące cele", - "skip": "Pomiń" + "skip": "Pomiń", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Dziennik gry", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 718f64963c..12d960252d 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -137,7 +137,9 @@ "incoming": "in", "mana": "mana", "selected": "sel", - "target": "target" + "target": "target", + "choice": "pick", + "sacrifice": "sac" }, "badgeTooltips": { "activate": "Hidden objects with activated abilities available.", @@ -146,7 +148,9 @@ "incoming": "Attacks currently aimed at hidden objects in this drawer.", "mana": "Hidden objects that can be tapped for mana.", "selected": "Hidden objects currently selected for the active choice.", - "target": "Hidden objects that are legal targets." + "target": "Hidden objects that are legal targets.", + "choice": "Hidden objects eligible for the active board choice.", + "sacrifice": "Hidden objects that can be sacrificed." } }, "permanent": { @@ -166,7 +170,19 @@ "copy": "Cópia", "copyTooltip": "Cópia em ficha de uma carta real", "ringBearer": "Anel", - "ringBearerTooltip": "Portador do Anel" + "ringBearerTooltip": "Portador do Anel", + "sacrifice": "Sacrifice", + "boardChoiceBadges": { + "sacrifice": "Sac", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "-1/-1", + "ringBearer": "Ring" + } }, "player": { "opponent": "Op {{seat}}", @@ -515,7 +531,15 @@ "keepCurrentTargets": "Manter Alvos Atuais", "skip": "Pular", "chooseNewTarget": "Escolha o novo alvo da mágica", - "chooseNewTargetForSpell": "Escolha um novo alvo para {{spell}}" + "chooseNewTargetForSpell": "Escolha um novo alvo para {{spell}}", + "sacrificeExact_one": "Sacrifice {{count}} permanent", + "sacrificeExact_other": "Sacrifice {{count}} permanents", + "sacrificeUpTo_one": "Sacrifice up to {{count}} permanent", + "sacrificeUpTo_other": "Sacrifice up to {{count}} permanents", + "sacrificeRange_one": "Sacrifice {{min}}-{{count}} permanent", + "sacrificeRange_other": "Sacrifice {{min}}-{{count}} permanents", + "confirmSacrifice": "Sacrifice ({{selected}}/{{count}})", + "skipSacrifice": "Skip Sacrifice" }, "log": { "title": "Registro do Jogo", @@ -1737,5 +1761,34 @@ "banner": "Spectating — read only", "leave": "Leave", "watchingWith": "Also watching: {{names}}" + }, + "boardChoice": { + "actions": { + "sacrifice": "Sacrifice", + "return": "Return", + "exile": "Exile", + "tap": "Tap", + "crew": "Crew", + "saddle": "Saddle", + "station": "Station", + "blight": "Blight", + "ringBearer": "Choose" + }, + "prompt": { + "single": "{{action}} a permanent", + "exactCount_one": "{{action}} {{count}} permanent", + "exactCount_other": "{{action}} {{count}} permanents", + "rangeCount_one": "{{action}} {{min}}-{{count}} permanent", + "rangeCount_other": "{{action}} {{min}}-{{count}} permanents", + "upToCount_one": "{{action}} up to {{count}} permanent", + "upToCount_other": "{{action}} up to {{count}} permanents", + "totalPower": "{{action}}: power {{selected}} / {{required}}" + }, + "confirm": "Confirm", + "confirmCount": "Confirm ({{selected}}/{{count}})", + "confirmPower": "Confirm ({{selected}}/{{required}} power)", + "skip": "Skip", + "groupCount": "{{selected}} / {{count}} selected", + "groupPower": "Power {{selected}} / {{required}}" } } diff --git a/client/src/viewmodel/gameStateView.ts b/client/src/viewmodel/gameStateView.ts index eb462a4473..13f9de1443 100644 --- a/client/src/viewmodel/gameStateView.ts +++ b/client/src/viewmodel/gameStateView.ts @@ -473,7 +473,15 @@ export function boardChoiceMaxSelection(choice: BoardChoiceView): number | null } export function isBoardChoiceImmediate(choice: BoardChoiceView): boolean { - return choice.selection.type === "single" || choice.selection.immediate === true; + switch (choice.selection.type) { + case "single": + return true; + case "exactCount": + return choice.selection.immediate === true; + case "rangeCount": + case "totalPowerAtLeast": + return false; + } } export function getBattlefieldSacrificeChoice(