diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index ccad40aa25..e0a867ccce 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -115,6 +115,7 @@ const viewerInteraction = { autoPassRecommended: false, opportunities: [], attachmentFans: {}, + attachmentViews: {}, availability: { type: "inputRequired" }, } as LegalActionsResult["viewerInteraction"]; diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 9a2a393660..63464cbb66 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -111,9 +111,18 @@ export type InteractionAttachmentFanChild = { objectId: number, submission: Inte export type InteractionAttachmentFan = { hostId: number, children: Array, }; +export type InteractionAttachmentViewCard = { objectId: number, submission: InteractionSubmission | null, }; + +export type InteractionAttachmentView = { hostId: number, cards: Array, }; + export type InteractionAvailability = { "type": "progressAvailable", "data": { witness: InteractionSubmission, } } | { "type": "inputRequired" } | { "type": "escapeOnly", "data": { reason: InteractionReasonCode, } } | { "type": "waiting" } | { "type": "terminal", "data": { outcome: InteractionOutcomeCode, } } | { "type": "unsupported", "data": { reason: InteractionReasonCode, } } | { "type": "stuck", "data": { reason: InteractionReasonCode, } }; -export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Record, availability: InteractionAvailability, }; +export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Record, +/** + * What is attached to each visible object, keyed by that object. Published + * on every projection, including the ones that carry no opportunity at all. + */ +attachmentViews: Record, availability: InteractionAvailability, }; export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, }; diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index 12829ed0b0..53459f2dd1 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; +import type { InteractionSubmission } from "../../adapter/generated/interaction/index.ts"; import type { ObjectId } from "../../adapter/types.ts"; import { dispatchAction, dispatchInteraction } from "../../game/dispatch.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; @@ -59,11 +60,17 @@ function fanCardSizingStyle(cardCount: number): CSSProperties { * independent object), and the fan lets the player choose which one without * hunting the peek. * - * The fan NEVER invents a choice. It has exactly two engine-owned sources: an - * open interaction's projection (mode 1), and — when no prompt is open — the - * permanent's own legal-action bucket read through `deriveActivationAffordances` - * (mode 2, the same authority the battlefield ring uses). Each card lights up - * (cyan) and dispatches only what one of those two offers for that object. Terminal + * Membership is not this component's decision. `viewerInteraction.attachmentViews` + * publishes what is attached to the host, ordered and both-direction validated by + * the engine; the fan renders that list and never scans `attachments` itself. + * + * The fan NEVER invents a choice either. It has exactly two engine-owned sources + * per card: the submission the projection published for that card (mode 1), and + * — for a card it published none for — the permanent's own legal-action bucket + * read through `deriveActivationAffordances` (mode 2, the same authority the + * battlefield ring uses). Both live side by side in one fan, because a published + * pick for one attachment says nothing about its neighbours. Each card lights up + * (cyan) and dispatches only what one of those two offers for that object. * One-step picks close the fan. Multi-step decisions stay in their dedicated * engine-authored interaction surfaces instead of asking this display to build * a response payload. @@ -98,25 +105,24 @@ export function AttachmentFan() { [affordances], ); const host = hostId != null ? objects?.[hostId] : undefined; - const interactionFan = useMemo( - () => - hostId == null - ? null - : (viewerInteraction?.attachmentFans[hostId] ?? null), + // THE membership authority: the engine publishes what is attached to this + // host, in its own order, with a submission on any card it published a + // one-step pick for. This display neither walks `attachments` nor decides who + // belongs in the fan — it renders the projection and counts it. + const attachmentView = useMemo( + () => (hostId == null ? null : (viewerInteraction?.attachmentViews[hostId] ?? null)), [hostId, viewerInteraction], ); + const submissionById = useMemo(() => { + const table = new Map(); + for (const card of attachmentView?.cards ?? []) { + if (card.submission !== null) table.set(card.objectId, card.submission); + } + return table; + }, [attachmentView]); - // During an interaction, the engine projection is the sole authority for - // which direct attachments belong in the fan. The fallback preserves the - // existing read-only badge outside an interaction, where no choice is being - // exposed and therefore no interaction capability exists to consume. const cardIds = host - ? [ - host.id, - ...(interactionFan - ? interactionFan.children.map((child) => child.objectId) - : host.attachments), - ] + ? [host.id, ...(attachmentView?.cards ?? []).map((card) => card.objectId)] : []; const close = useCallback(() => { @@ -148,12 +154,13 @@ export function AttachmentFan() { const handlePick = useCallback( (id: ObjectId) => { - // Mode 1 — an engine interaction owns the prompt: the projection is the sole - // authority and the fan only forwards its opaque submission. UNCHANGED. - if (interactionFan !== null) { - const child = interactionFan.children.find((candidate) => candidate.objectId === id); - if (!child || !viewerInteraction?.canSubmit) return; - void dispatchInteraction(child.submission).then(close).catch(notifyFailure); + // Mode 1 — the engine published a pick for THIS card: forward its opaque + // submission, nothing else. Decided per card, because the projection + // carries a pick for some members and none for others. + const submission = submissionById.get(id); + if (submission) { + if (!viewerInteraction?.canSubmit) return; + void dispatchInteraction(submission).then(close).catch(notifyFailure); return; } // Mode 2 — no prompt is open, so the fan is a reachability surface for the @@ -199,14 +206,16 @@ export function AttachmentFan() { affordances, canActivate, close, - interactionFan, + submissionById, notifyFailure, setPendingAbilityChoice, viewerInteraction?.canSubmit, ], ); - if (hostId == null || !host || cardIds.length === 0) return null; + // A fan of just the host is not a fan. With no published membership there is + // nothing to spread, and this display does not go looking for members itself. + if (hostId == null || !host || (attachmentView?.cards.length ?? 0) === 0) return null; // Shared compact whole-row fan — sized by the total card count so the host + // its attachments stay within the overlay's viewport budget. @@ -239,7 +248,11 @@ export function AttachmentFan() { rotation={fan.rotation(i)} arcOffset={fan.arc(i)} zIndex={i} - selectable={id !== host.id && (interactionFan !== null || canActivate(id))} + selectable={ + id !== host.id && + ((submissionById.has(id) && viewerInteraction?.canSubmit === true) || + canActivate(id)) + } onPick={handlePick} /> ))} diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index 44930fbe14..764011ad1c 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -473,9 +473,11 @@ export const PermanentCard = memo(function PermanentCard({ (s) => obj && s.gameState?.players?.find((p) => p.id === obj.controller)?.commander_color_identity, ); const viewerInteraction = useGameStore((s) => s.viewerInteraction); - const interactionAttachmentFan = useMemo( - () => - viewerInteraction?.attachmentFans[objectId] ?? null, + // The engine's own list of what is attached to this permanent, with a + // submission on each card it published a pick for. Same field `AttachmentFan` + // renders, so the badge's label can never promise a card the fan won't show. + const attachmentView = useMemo( + () => viewerInteraction?.attachmentViews[objectId] ?? null, [objectId, viewerInteraction], ); @@ -505,10 +507,13 @@ export const PermanentCard = memo(function PermanentCard({ const ptDisplay = computePTDisplay(obj); const isSelected = selectedObjectId === objectId; - // The viewer-scoped engine projection owns both the direct-attachment - // relationship and whether one is actionable for this interaction. The - // board must not rediscover either fact from the raw snapshot. - const attachmentsActionable = interactionAttachmentFan !== null; + // The viewer-scoped engine projection owns both the attachment relationship + // and whether one is actionable for this interaction. The board must not + // rediscover either fact from the raw snapshot. "Actionable" is per card: + // the projection lists every attachment and marks the ones it published a + // pick for, so this asks whether ANY of them carries one. + const attachmentsActionable = + attachmentView?.cards.some((card) => card.submission !== null) ?? false; const attachmentsLifted = obj.attachments.length > 0 && (attachmentsLiftedByAncestor || isInHoveredAttachmentTree); @@ -517,6 +522,12 @@ export const PermanentCard = memo(function PermanentCard({ const attachmentPathIds = new Set([...attachmentRenderPath, objectId]); const renderableAttachmentIds = visibleAttachmentIds.filter((id) => !attachmentPathIds.has(id)); const hiddenAttachmentCount = obj.attachments.length - visibleAttachmentIds.length; + // What the fan will actually put on screen, for the `⧉` control's label: the + // very list the fan renders. Counting `obj.attachments` here would be both a + // second derivation and a wrong number — the projection also carries the + // attachments OF the attachments, which the fan shows and the peek stack + // cannot. + const attachmentFanCardCount = attachmentView?.cards.length ?? 0; const exileLinksExpanded = exileLinks.length <= 1 || isHovered || isSelected || isInspected; const visibleExileLinks = exileLinksExpanded ? exileLinks : exileLinks.slice(0, 1); const hiddenExileCount = exileLinks.length - visibleExileLinks.length; @@ -684,12 +695,6 @@ export const PermanentCard = memo(function PermanentCard({ }); } else if (isValidTarget) { dispatchAction({ type: "ChooseTarget", data: { target: { Object: objectId } } }); - } else if (attachmentsActionable) { - // The host is not a legal choice, but one of its attachments is. Open - // the full-card chooser rather than requiring a precise click on an - // overlapping attachment peek. The fan derives every selectable card - // from the engine's current legal-target set. - showAttachmentFan(); } else if (isActivatable) { // THE single authority for "what does a click on this bucket do" // (viewmodel/cardActionChoice.ts). Owns the CR 605.1a mana/non-mana @@ -723,6 +728,25 @@ export const PermanentCard = memo(function PermanentCard({ } } else if (isUndoableTap) { dispatchAction({ type: "UntapLandForMana", data: { object_id: objectId } }); + } else if (attachmentsActionable) { + // The host is not a legal choice, but one of its attachments is. Open + // the full-card chooser rather than requiring a precise click on an + // overlapping attachment peek. The fan derives every selectable card + // from the engine's current legal-target set. + // + // Placed after the host's own target / activation / undo intent for the + // same reason the affordance-set branch below is placed last: the premise + // "the host is not a legal choice" is not something this branch can see. + // During Priority `HumanResponseModel::ExactCandidates` publishes a fan for + // EVERY activatable attachment, so the fan's existence says nothing about + // the host — and while this sat above `isActivatable`, a creature with its + // own ability was unreachable whenever an Aura or Equipment on it was also + // activatable. The fan cannot stand in for the host either: it excludes the + // host by design (`AttachmentFan.tsx`, `id !== host.id`), so the host's + // ability had no path at all. Reported for Slumbering Keepguard under + // Cooped Up, whose `{2}{W}` is legitimately activatable from the + // battlefield — no engine defect required. + showAttachmentFan(); } else if ( obj.attachments.some( (attachId) => activatableObjectIds.has(attachId) || manaTappableObjectIds.has(attachId), @@ -1106,7 +1130,42 @@ export const PermanentCard = memo(function PermanentCard({ /> )} - {obj.attachments.length === 1 && ( + {/* The explicit route into the fan, and the ONLY one once the host's own + click belongs to the host (see the `attachmentsActionable` branch). The + `+N` control above covers the collapsed case and this covers the + expanded one — `attachmentsExpanded` is the same predicate `+N` is + derived from, so the two are complementary by construction and exactly + one entry point renders in every state. + Was `length === 1`, which left a host with SEVERAL expanded attachments + with no entry point at all — the state Priority produces, because + `attachmentsActionable` is itself one of the disjuncts that expands the + stack, and each attachment is then reachable only through a ~22px peek + rendered behind the host face. + Two states the gate deliberately leaves without a control, so the + "complementary" claim above is not unconditional: with no attachments + neither renders and none is needed, and on a NESTED host the button is + painted inside the peek wrapper's `zIndex: 5 - i` and so sits under the + parent's card face, focus ring included — the working fallback there is + that host's OWN peek, which opens a fan keyed to it, and since a fan + lists a host plus its direct children that is one hop per level and + therefore enough. + The size is left exactly as it was, deliberately. This control is now + the pointer route where the host's own click used to open the fan, and + at `clamp(20px, …, 28px)` it is under the 44px floor the branch above + cites — but 44px is not reachable here. Battlefield cards sit in an + 8px gap (`BattlefieldRow.tsx:176`, `const gap = 8`) and `--card-base` + floors at 3.5rem, so at `-left-2.5` the badge already overhangs the + gap; growing outward to 44px would put ~26px over the NEIGHBOUR's face + at `z-40` and steal its clicks, and growing inward would swallow most + of a 56px card — either way re-creating, in miniature, the click theft + this branch exists to undo. A sub-44px target that takes only its own + corner is the better trade; the floor needs a layout-level answer + (badge sizes are shared with `+N` and the group-expand control at + `GroupedPermanent.tsx:279`, which caps its own overhang at 12px). */} + {/* Gated on the projected count, not on `obj.attachments`: the control + opens a fan built from the projection, so if the engine published no + membership there is nothing behind the badge to show. */} + {attachmentFanCardCount > 0 && attachmentsExpanded && (