diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 5438fced26..8374862876 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1263,7 +1263,7 @@ export type WaitingFor = } | { type: "PayAmountChoice"; data: { player: PlayerId; resource: PayableResource; min: number; max: number; accumulated?: number; source_id: ObjectId; pending_mana_ability?: unknown } } | { type: "TargetSelection"; data: { player: PlayerId; pending_cast: PendingCast; target_slots: TargetSelectionSlot[]; mode_labels?: (string | null)[]; selection: TargetSelectionProgress } } - | { type: "DeclareAttackers"; data: { player: PlayerId; valid_attacker_ids: ObjectId[]; valid_attack_targets?: AttackTarget[] } } + | { type: "DeclareAttackers"; data: { player: PlayerId; valid_attacker_ids: ObjectId[]; valid_attack_targets?: AttackTarget[]; valid_attack_targets_by_attacker?: Record } } | { type: "DeclareBlockers"; data: { player: PlayerId; valid_blocker_ids: ObjectId[]; valid_block_targets: Record; block_requirements?: Record } } | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } } diff --git a/client/src/components/board/ActionButton.tsx b/client/src/components/board/ActionButton.tsx index 3edd12ae93..4ea940c2d2 100644 --- a/client/src/components/board/ActionButton.tsx +++ b/client/src/components/board/ActionButton.tsx @@ -10,7 +10,11 @@ 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 { buildAttacks, hasMultipleAttackTargets, getValidAttackTargets } from "../../utils/combat.ts"; +import { + buildAttacks, + getValidAttackTargets, + selectedAttackersNeedTargetPicker, +} from "../../utils/combat.ts"; import { useBlockRequirements } from "../combat/useBlockRequirements.ts"; import { gameButtonClass } from "../ui/buttonStyles.ts"; import { GameplayTooltip } from "../ui/GameplayTooltip.tsx"; @@ -98,8 +102,11 @@ export function ActionButton() { // Attack target picker visibility (multiplayer) const [showTargetPicker, setShowTargetPicker] = useState(false); - const isMultiTarget = hasMultipleAttackTargets(gameState); const validAttackTargets = getValidAttackTargets(gameState); + const validAttackTargetsByAttacker = + waitingFor?.type === "DeclareAttackers" + ? (waitingFor.data.valid_attack_targets_by_attacker ?? {}) + : {}; // Reset skip-confirm when mode changes useEffect(() => { @@ -216,7 +223,7 @@ export function ActionButton() { } function handleConfirmAttackers() { - if (isMultiTarget) { + if (selectedAttackersNeedTargetPicker(gameState, selectedAttackers)) { setShowTargetPicker(true); return; } @@ -490,6 +497,7 @@ export function ActionButton() { {showTargetPicker && ( setShowTargetPicker(false)} diff --git a/client/src/components/controls/AttackTargetPicker.tsx b/client/src/components/controls/AttackTargetPicker.tsx index 1478cfab42..1a5a53988f 100644 --- a/client/src/components/controls/AttackTargetPicker.tsx +++ b/client/src/components/controls/AttackTargetPicker.tsx @@ -3,23 +3,22 @@ import { motion, useReducedMotion } from "framer-motion"; import { Trans, useTranslation } from "react-i18next"; import type { AttackTarget, GameObject, ObjectId, PlayerId } from "../../adapter/types.ts"; -import { getSeatColor } from "../../hooks/useSeatColor.ts"; import { useInspectHoverProps } from "../../hooks/useInspectHoverProps.ts"; +import { usePlayerId } from "../../hooks/usePlayerId.ts"; +import { getSeatColor } from "../../hooks/useSeatColor.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { getPlayerDisplayName } from "../../stores/multiplayerStore.ts"; -import { usePlayerId } from "../../hooks/usePlayerId.ts"; -import { formatCounterType } from "../../viewmodel/cardProps.ts"; import { type AttackerStack, evenSplit, groupAttackers } from "../../utils/combat.ts"; -import { gameButtonClass } from "../ui/buttonStyles.ts"; +import { formatCounterType } from "../../viewmodel/cardProps.ts"; import { PeekTab } from "../modal/DialogShell.tsx"; +import { gameButtonClass } from "../ui/buttonStyles.ts"; import { CounterTooltip } from "../ui/CounterTooltip.tsx"; -/** Internal assignment map: every attacker maps to its chosen target, or `null` - * while it sits in the Unassigned bucket. */ type AssignmentMap = Map; interface AttackTargetPickerProps { validTargets: AttackTarget[]; + validTargetsByAttacker?: Record; selectedAttackers: ObjectId[]; onConfirm: (attacks: [ObjectId, AttackTarget][]) => void; onCancel: () => void; @@ -39,16 +38,10 @@ interface AttackTargetPickerProps { * * Frontend display layer only: it merely arranges the attacker→target choices * the player makes and hands the flat array to the engine, which validates it. - * - * Layout mirrors {@link DialogShell}: a `relative` wrapper hosts the `PeekTab` - * as a sibling OUTSIDE the `overflow-hidden` card, and the card is a flex column - * with a pinned header, a single scrollable body, and a pinned footer. Keeping - * the tab out of the scroll container is what stops its ~12px `translate-x-1/3` - * overhang from forcing a stray horizontal scrollbar; making the body the only - * scroll region keeps the actions on-screen and any needed scrollbar thin. */ export function AttackTargetPicker({ validTargets, + validTargetsByAttacker, selectedAttackers, onConfirm, onCancel, @@ -56,8 +49,6 @@ export function AttackTargetPicker({ const { t } = useTranslation("game"); const [mode, setMode] = useState<"all" | "distribute">("all"); const [peeked, setPeeked] = useState(false); - // Every attacker starts in the Unassigned bucket (null). Keyed by attacker - // ObjectId so a stack's members can land on different targets. const [assignments, setAssignments] = useState( () => new Map(selectedAttackers.map((id) => [id, null] as const)), ); @@ -68,31 +59,37 @@ export function AttackTargetPicker({ const myId = usePlayerId(); const hoverProps = useInspectHoverProps(); const seatOrder = gameState?.seat_order; - const teamBased = gameState?.format_config?.team_based ?? false; + function targetsForCreature(creatureId: ObjectId): AttackTarget[] { + return validTargetsByAttacker?.[String(creatureId)] ?? validTargets; + } + const sortedTargets = useMemo(() => { if (!seatOrder) return validTargets; return [...validTargets].sort((a, b) => { const aIdx = a.type === "Player" ? seatOrder.indexOf(a.data) : Infinity; const bIdx = b.type === "Player" ? seatOrder.indexOf(b.data) : Infinity; if (aIdx !== bIdx) return aIdx - bIdx; - // Total order: two non-Player targets both map to Infinity (as do any equal - // seat-index ties), so tie-break on the numeric id. Without this the - // comparator returns `Infinity - Infinity === NaN` for a pair of - // planeswalkers/battles, leaving their order — and thus which defender takes - // the front-loaded even-split remainder — dependent on JS sort stability. return Number(a.data) - Number(b.data); }); }, [validTargets, seatOrder]); - // Stacks of identical attackers, reusing the battlefield grouping block. + const commonTargets = useMemo( + () => + sortedTargets.filter((target) => + selectedAttackers.every((id) => + targetsForCreature(id).some((candidate) => sameAttackTarget(candidate, target)), + ), + ), + [selectedAttackers, sortedTargets, validTargetsByAttacker, validTargets], + ); + const stacks = useMemo( () => groupAttackers(selectedAttackers, gameState), [selectedAttackers, gameState], ); - // Total attackers still in the Unassigned bucket — gates Confirm. const unassignedTotal = useMemo( () => selectedAttackers.reduce((n, id) => n + (assignments.get(id) == null ? 1 : 0), 0), [assignments, selectedAttackers], @@ -118,9 +115,6 @@ export function AttackTargetPicker({ onConfirm(selectedAttackers.map((id) => [id, target])); } - // --- Distribute-mode assignment mutations (all clone-then-mutate; pure - // transforms live at module scope for deterministic, testable moves). --- - function mutate(fn: (next: AssignmentMap) => void) { setAssignments((prev) => { const next = new Map(prev); @@ -129,15 +123,13 @@ export function AttackTargetPicker({ }); } - /** +1: claim the lowest-id Unassigned member of the stack for this target. */ function incOnTarget(stack: AttackerStack, target: AttackTarget) { mutate((next) => { - const id = lowestUnassigned(stack, next); + const id = lowestUnassignedForTarget(stack, target, next, targetsForCreature); if (id != null) next.set(id, target); }); } - /** -1: release the highest-id member currently on this target back to Unassigned. */ function decFromTarget(stack: AttackerStack, target: AttackTarget) { mutate((next) => { const id = highestOnTarget(stack, target, next); @@ -145,33 +137,32 @@ export function AttackTargetPicker({ }); } - /** Send the entire stack to one target (overrides members already elsewhere). */ function allOfStackToTarget(stack: AttackerStack, target: AttackTarget) { + if (!stackFullySupportsTarget(stack, target, targetsForCreature)) return; mutate((next) => { for (const id of stack.ids) next.set(id, target); }); } - /** Spread one stack evenly across every target. */ function spreadStack(stack: AttackerStack) { - mutate((next) => spreadStackEvenly(next, stack, sortedTargets)); + mutate((next) => spreadStackEvenly(next, stack, sortedTargets, targetsForCreature)); } - /** Spread every selected attacker evenly across every target. */ function spreadAll() { mutate((next) => { - spreadAttackersEvenly(next, stacks.flatMap((stack) => stack.ids), sortedTargets); + for (const stack of stacks) { + spreadStackEvenly(next, stack, sortedTargets, targetsForCreature); + } }); } - /** Send every attacker of every stack to one target. */ function allStacksToTarget(target: AttackTarget) { + if (!commonTargets.some((candidate) => sameAttackTarget(candidate, target))) return; mutate((next) => { for (const id of selectedAttackers) next.set(id, target); }); } - /** Return every attacker to the Unassigned bucket. */ function resetAll() { mutate((next) => { for (const id of selectedAttackers) next.set(id, null); @@ -181,8 +172,8 @@ export function AttackTargetPicker({ function countOnTarget(stack: AttackerStack, target: AttackTarget): number { const key = attackTargetKey(target); return stack.ids.reduce((n, id) => { - const t = assignments.get(id); - return n + (t && attackTargetKey(t) === key ? 1 : 0); + const assigned = assignments.get(id); + return n + (assigned && attackTargetKey(assigned) === key ? 1 : 0); }, 0); } @@ -191,7 +182,6 @@ export function AttackTargetPicker({ } function handleDistributeConfirm() { - // The gate guarantees no nulls, but flatMap also makes the types sound. const attacks = selectedAttackers.flatMap((id): [ObjectId, AttackTarget][] => { const target = assignments.get(id); return target ? [[id, target]] : []; @@ -199,10 +189,7 @@ export function AttackTargetPicker({ onConfirm(attacks); } - const slideTransform = peeked - ? { x: "calc(100vw - 32px)" } - : { x: 0 }; - + const slideTransform = peeked ? { x: "calc(100vw - 32px)" } : { x: 0 }; const sidePadding = mode === "all" ? "px-8" : "px-4 sm:px-6"; return ( @@ -217,24 +204,15 @@ export function AttackTargetPicker({ : { type: "spring", stiffness: 320, damping: 32 } } > - {/* Wrapper: positioning context for the PeekTab, which sits at the - wrapper's right edge OUTSIDE the card. The card clips its own overflow - (overflow-hidden), so the tab's ~12px `translate-x-1/3` overhang no - longer forces a stray horizontal scrollbar the way it did when the tab - lived inside the scroll container. Mirrors DialogShell. `w-full` + - `max-w-*` lets the card shrink to fit narrow phones instead of forcing - a fixed 420px that would overflow. */}
- {/* Header — pinned, never scrolls */}

{t("attackTargetPicker.heading")}

- {/* Mode toggle */}
- {/* Body — the ONLY scroll region. `overflow-x-hidden` pins the cross - axis so a marginally-wide child can't sprout the very horizontal - scrollbar this layout removes (the desktop matrix keeps its own - `overflow-x-auto`); `overscroll-contain` stops a mobile scroll from - chaining to the board behind; `thin-scrollbar` keeps any needed - scrollbar unobtrusive. */} -
+
{mode === "all" ? ( - /* Attack All mode: one button per target */
- {sortedTargets.map((target) => { + {commonTargets.map((target) => { const color = getTargetSeatColor(target); return ( @@ -289,34 +270,43 @@ export function AttackTargetPicker({ })}
) : ( - /* Distribute mode: per-target buckets with steppers + shortcuts */
- {/* Global shortcuts + gate hint */}
-

0 ? "text-amber-300" : "text-emerald-300"}`}> +

0 ? "text-amber-300" : "text-emerald-300"}`} + > {unassignedTotal > 0 - ? t("attackTargetPicker.unassignedRemaining", { count: unassignedTotal }) + ? t("attackTargetPicker.unassignedRemaining", { + count: unassignedTotal, + }) : t("attackTargetPicker.allAssigned")}

- {/* Desktop: stacks (rows) × buckets (columns) matrix */}
@@ -329,20 +319,32 @@ export function AttackTargetPicker({ {sortedTargets.map((target) => { const color = getTargetSeatColor(target); + const canAssignAll = commonTargets.some((candidate) => + sameAttackTarget(candidate, target), + ); return ( - {stacks.map((stack) => { const unassigned = countUnassigned(stack); + const canSpread = stack.ids.some( + (id) => targetsForCreature(id).length > 0, + ); return ( ); @@ -407,27 +440,40 @@ export function AttackTargetPicker({
+
- - {getTargetLabel(target)} + + + {getTargetLabel(target)} + @@ -355,15 +357,22 @@ export function AttackTargetPicker({
- +
0 ? "bg-amber-900/60 text-amber-100" : "text-gray-600" + unassigned > 0 + ? "bg-amber-900/60 text-amber-100" + : "text-gray-600" }`} > {unassigned} @@ -383,19 +394,41 @@ export function AttackTargetPicker({ {sortedTargets.map((target) => { const count = countOnTarget(stack, target); const label = getTargetLabel(target); + const canInc = + lowestUnassignedForTarget( + stack, + target, + assignments, + targetsForCreature, + ) != null; + const canAll = stackFullySupportsTarget( + stack, + target, + targetsForCreature, + ); return ( - + 0} - canInc={unassigned > 0} + canInc={canInc} + canAll={canAll} onDec={() => decFromTarget(stack, target)} onInc={() => incOnTarget(stack, target)} onAll={() => allOfStackToTarget(stack, target)} - decTitle={t("attackTargetPicker.removeOne", { label })} - incTitle={t("attackTargetPicker.assignOne", { label })} - allTitle={t("attackTargetPicker.assignAllHere", { label })} + decTitle={t("attackTargetPicker.removeOne", { + label, + })} + incTitle={t("attackTargetPicker.assignOne", { + label, + })} + allTitle={t("attackTargetPicker.assignAllHere", { + label, + })} />
- {/* Mobile: per-stack accordion driving the same assignment state */}
{stacks.map((stack) => { const unassigned = countUnassigned(stack); const expanded = expandedStack === stack.key; + const canSpread = stack.ids.some( + (id) => targetsForCreature(id).length > 0, + ); return ( -
+
{expanded && ( @@ -444,16 +494,24 @@ export function AttackTargetPicker({
- {t("attackTargetPicker.unassigned")} + + {t("attackTargetPicker.unassigned")} + 0 ? "bg-amber-900/60 text-amber-100" : "text-gray-600" + unassigned > 0 + ? "bg-amber-900/60 text-amber-100" + : "text-gray-600" }`} > {unassigned} @@ -463,23 +521,51 @@ export function AttackTargetPicker({ const color = getTargetSeatColor(target); const count = countOnTarget(stack, target); const label = getTargetLabel(target); + const canInc = + lowestUnassignedForTarget( + stack, + target, + assignments, + targetsForCreature, + ) != null; + const canAll = stackFullySupportsTarget( + stack, + target, + targetsForCreature, + ); return ( -
- - +
+ + {label} 0} - canInc={unassigned > 0} + canInc={canInc} + canAll={canAll} onDec={() => decFromTarget(stack, target)} onInc={() => incOnTarget(stack, target)} onAll={() => allOfStackToTarget(stack, target)} - decTitle={t("attackTargetPicker.removeOne", { label })} - incTitle={t("attackTargetPicker.assignOne", { label })} - allTitle={t("attackTargetPicker.assignAllHere", { label })} + decTitle={t("attackTargetPicker.removeOne", { + label, + })} + incTitle={t("attackTargetPicker.assignOne", { + label, + })} + allTitle={t("attackTargetPicker.assignAllHere", { + label, + })} />
); @@ -494,22 +580,32 @@ export function AttackTargetPicker({ )}
- {/* Footer — pinned actions, so Confirm/Cancel never scroll away */}
{mode === "distribute" && ( )} @@ -528,19 +624,27 @@ function objectPtLabel(obj: GameObject | undefined): string | null { return `${obj.power}/${obj.toughness}`; } -function objectCounterChips(obj: GameObject | undefined): Array<{ type: string; count: number }> { +function objectCounterChips( + obj: GameObject | undefined, +): Array<{ type: string; count: number }> { if (!obj) return []; return Object.entries(obj.counters) - .filter((entry): entry is [string, number] => entry[1] != null && entry[1] > 0 && entry[0] !== "loyalty") + .filter( + (entry): entry is [string, number] => + entry[1] != null && entry[1] > 0 && entry[0] !== "loyalty", + ) .sort(([a], [b]) => a.localeCompare(b)) .map(([type, count]) => ({ type, count })); } -/** Stable key for an AttackTarget. */ function attackTargetKey(target: AttackTarget): string { return `${target.type}-${target.data}`; } +function sameAttackTarget(a: AttackTarget, b: AttackTarget): boolean { + return a.type === b.type && a.data === b.data; +} + function RestoreTab({ onClick }: { onClick: () => void }) { const { t } = useTranslation("game"); return ( @@ -582,50 +686,87 @@ function RestoreTab({ onClick }: { onClick: () => void }) { ); } -// --- Pure assignment transforms (deterministic; mutate the passed map). --- - -/** Lowest-id member of the stack currently in the Unassigned bucket, or null. */ -function lowestUnassigned(stack: AttackerStack, map: AssignmentMap): ObjectId | null { +function lowestUnassignedForTarget( + stack: AttackerStack, + target: AttackTarget, + map: AssignmentMap, + targetsForCreature: (creatureId: ObjectId) => AttackTarget[], +): ObjectId | null { for (const id of stack.ids) { - if (map.get(id) == null) return id; + if (map.get(id) != null) continue; + if (targetsForCreature(id).some((candidate) => sameAttackTarget(candidate, target))) { + return id; + } } return null; } -/** Highest-id member of the stack currently assigned to `target`, or null. */ -function highestOnTarget(stack: AttackerStack, target: AttackTarget, map: AssignmentMap): ObjectId | null { +function highestOnTarget( + stack: AttackerStack, + target: AttackTarget, + map: AssignmentMap, +): ObjectId | null { const key = attackTargetKey(target); for (let i = stack.ids.length - 1; i >= 0; i--) { - const t = map.get(stack.ids[i]); - if (t && attackTargetKey(t) === key) return stack.ids[i]; + const assigned = map.get(stack.ids[i]); + if (assigned && attackTargetKey(assigned) === key) { + return stack.ids[i]; + } } return null; } -/** - * Redistribute a whole stack evenly across `targets` (overrides prior - * assignments). Members are walked in ascending-id order and handed to targets - * in display order, with the remainder front-loaded by {@link evenSplit}. - */ -function spreadStackEvenly(map: AssignmentMap, stack: AttackerStack, targets: AttackTarget[]): void { - spreadAttackersEvenly(map, stack.ids, targets); +function stackFullySupportsTarget( + stack: AttackerStack, + target: AttackTarget, + targetsForCreature: (creatureId: ObjectId) => AttackTarget[], +): boolean { + return stack.ids.every((id) => + targetsForCreature(id).some((candidate) => sameAttackTarget(candidate, target)), + ); } /** - * Redistribute attackers evenly across `targets` (overrides prior assignments). - * Attackers are walked in stable UI order and handed to targets in display - * order, with the remainder front-loaded by {@link evenSplit}. + * Redistribute a stack as evenly as legality allows across display-order + * targets. Uses the shared `evenSplit` building block to compute per-target + * capacities (remainder front-loaded to the earliest targets), then fills + * targets in display order with ascending-id members — so the lowest-id + * members claim the earliest targets deterministically, matching the per-target + * stepper's "lowest-id unassigned member" convention. */ -function spreadAttackersEvenly(map: AssignmentMap, attackerIds: ObjectId[], targets: AttackTarget[]): void { +function spreadStackEvenly( + map: AssignmentMap, + stack: AttackerStack, + targets: AttackTarget[], + targetsForCreature: (creatureId: ObjectId) => AttackTarget[], +): void { if (targets.length === 0) return; - const counts = evenSplit(attackerIds.length, targets.length); - let member = 0; - targets.forEach((target, ti) => { - for (let k = 0; k < counts[ti]; k++) { - map.set(attackerIds[member], target); - member += 1; + const split = evenSplit(stack.ids.length, targets.length); + const cap = new Map(); + for (let i = 0; i < targets.length; i++) { + cap.set(attackTargetKey(targets[i]), split[i]); + } + const used = new Map(); + for (const id of stack.ids) { + const legalTargets = targets.filter((target) => + targetsForCreature(id).some((candidate) => sameAttackTarget(candidate, target)), + ); + if (legalTargets.length === 0) { + map.set(id, null); + continue; } - }); + // Assign to the first legal target (display order) with remaining capacity; + // fall back to the first legal target when all are full (overfill rather + // than leave the member unassigned). + const target = + legalTargets.find((t) => { + const key = attackTargetKey(t); + return (used.get(key) ?? 0) < (cap.get(key) ?? 0); + }) ?? legalTargets[0]; + map.set(id, target); + const key = attackTargetKey(target); + used.set(key, (used.get(key) ?? 0) + 1); + } } interface StepperCellProps { @@ -633,6 +774,7 @@ interface StepperCellProps { color?: string; canInc: boolean; canDec: boolean; + canAll: boolean; onDec: () => void; onInc: () => void; onAll: () => void; @@ -641,11 +783,19 @@ interface StepperCellProps { allTitle: string; } -/** `[ − ] N [ + ]` for one (stack, bucket) cell. The count doubles as a button - * that sends the whole stack to this bucket. Buttons are enlarged below `md` - * (the breakpoint where the mobile accordion — not the compact desktop matrix — - * is shown) for comfortable touch targets without widening the desktop table. */ -function StepperCell({ count, color, canInc, canDec, onDec, onInc, onAll, decTitle, incTitle, allTitle }: StepperCellProps) { +function StepperCell({ + count, + color, + canInc, + canDec, + canAll, + onDec, + onInc, + onAll, + decTitle, + incTitle, + allTitle, +}: StepperCellProps) { return (