diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ab1ff095f8..2c4fc78719 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -322,7 +322,7 @@ export interface MeldSelection { // display-only badges + Confirm gating. `#[serde(tag = "kind")]` in the engine. export type CombatRequirement = | { kind: "MustAttack"; players: PlayerId[]; sources?: ObjectId[] } - | { kind: "MustBlock"; sources?: ObjectId[] } + | { kind: "MustBlock"; sources?: ObjectId[]; attackers?: ObjectId[] } | { kind: "CantAttack"; sources?: ObjectId[] } | { kind: "CantBlock"; sources?: ObjectId[] }; diff --git a/client/src/components/board/ActionButton.tsx b/client/src/components/board/ActionButton.tsx index 2edb945120..f7c763e475 100644 --- a/client/src/components/board/ActionButton.tsx +++ b/client/src/components/board/ActionButton.tsx @@ -9,10 +9,8 @@ import { usePhaseInfo } from "../../hooks/usePhaseInfo.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { DRAFT_BOT_AI_SEAT, useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore.ts"; import { useMultiplayerStore } from "../../stores/multiplayerStore.ts"; -import { useUiStore } from "../../stores/uiStore.ts"; +import { blockerAssignmentPairs, useUiStore } from "../../stores/uiStore.ts"; import { buildAttacks, hasMultipleAttackTargets, getValidAttackTargets, getValidAttackTargetsByAttacker } from "../../utils/combat.ts"; -import { useBlockRequirements } from "../combat/useBlockRequirements.ts"; -import { useBlockerConstraints } from "../combat/useBlockerConstraints.ts"; import { gameButtonClass } from "../ui/buttonStyles.ts"; import { GameplayTooltip } from "../ui/GameplayTooltip.tsx"; import { AttackTargetPicker } from "../controls/AttackTargetPicker.tsx"; @@ -72,20 +70,10 @@ export function ActionButton() { const setCombatMode = useUiStore((s) => s.setCombatMode); const setCombatClickHandler = useUiStore((s) => s.setCombatClickHandler); - // Engine-declared per-attacker minimum-blocker requirements (menace / - // "blocked by N or more"). Used to block confirmation while any attacker is - // under-assigned, so the player gets a clear message instead of an engine - // rejection (CR 702.111b / CR 509.1b). - const { byAttacker: blockRequirements } = useBlockRequirements(); - const incompleteBlockCount = useMemo( - () => Array.from(blockRequirements.values()).filter((r) => r.status === "incomplete").length, - [blockRequirements], + const blockerPairs = useMemo( + () => blockerAssignmentPairs(blockerAssignments), + [blockerAssignments], ); - // CR 509.1c: engine-provided must-block gating. Attacker must-attack - // requirements are NOT gated client-side — the engine strictly validates the - // declaration (CR 508.1d) and rejects an illegal submission; the must-attack - // badges (see AttackRequirementBadges / useAttackRequirements) are display only. - const { unsatisfiedMustBlockCount } = useBlockerConstraints(); const canCompanionToHand = useGameStore((s) => s.legalActions.some((a) => a.type === "CompanionToHand"), @@ -149,26 +137,32 @@ export function ActionButton() { [waitingFor], ); + // A new declaration prompt invalidates a partially selected blocker from the + // prior prompt, even though both prompts share the same combat mode. + const blockerPrompt = waitingFor?.type === "DeclareBlockers" ? waitingFor : null; + useEffect(() => { + setPendingBlocker(null); + }, [blockerPrompt]); + // Blocker click handler const handleBlockerClick = useCallback( (objectId: ObjectId) => { - // Click an already-assigned blocker to unassign - if (blockerAssignments.has(objectId)) { - removeBlockerAssignment(objectId); + // Selecting a blocker never clears its other assignments: one blocker can + // be assigned to multiple attackers. A second click on an attacker toggles + // only that pair, using the engine-provided candidate list. + if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) { + setPendingBlocker((current) => current === objectId ? null : objectId); return; } - if (pendingBlocker === null) { - // First click: select a valid blocker (must have at least one valid target) - if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) { - setPendingBlocker(objectId); - } - } else { - // Second click: assign to an attacker (only if engine says this pair is valid) + if (pendingBlocker !== null) { const validTargetsForBlocker = validBlockTargets[pendingBlocker] ?? []; if (combatAttackerIds.includes(objectId) && validTargetsForBlocker.includes(objectId)) { - assignBlocker(pendingBlocker, objectId); - setPendingBlocker(null); + if (blockerAssignments.get(pendingBlocker)?.has(objectId)) { + removeBlockerAssignment(pendingBlocker, objectId); + } else { + assignBlocker(pendingBlocker, objectId); + } } } }, @@ -241,7 +235,7 @@ export function ActionButton() { function handleConfirmBlockers() { dispatchAction({ type: "DeclareBlockers", - data: { assignments: Array.from(blockerAssignments.entries()) }, + data: { assignments: blockerPairs }, }); } @@ -317,14 +311,14 @@ export function ActionButton() { {mode === "combat-blockers" && ( <> - {blockerAssignments.size > 0 ? ( + {blockerPairs.length > 0 ? ( <>