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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const viewerInteraction = {
autoPassRecommended: false,
opportunities: [],
attachmentFans: {},
attachmentViews: {},
availability: { type: "inputRequired" },
} as LegalActionsResult["viewerInteraction"];

Expand Down
11 changes: 10 additions & 1 deletion client/src/adapter/generated/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,18 @@ export type InteractionAttachmentFanChild = { objectId: number, submission: Inte

export type InteractionAttachmentFan = { hostId: number, children: Array<InteractionAttachmentFanChild>, };

export type InteractionAttachmentViewCard = { objectId: number, submission: InteractionSubmission | null, };

export type InteractionAttachmentView = { hostId: number, cards: Array<InteractionAttachmentViewCard>, };

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<number>, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array<InteractionOpportunity>, attachmentFans: Record<number, InteractionAttachmentFan>, availability: InteractionAvailability, };
export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array<number>, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array<InteractionOpportunity>, attachmentFans: Record<number, InteractionAttachmentFan>,
/**
* 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<number, InteractionAttachmentView>, availability: InteractionAvailability, };

export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, };

Expand Down
71 changes: 42 additions & 29 deletions client/src/components/board/AttachmentFan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<ObjectId, InteractionSubmission>();
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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
/>
))}
Expand Down
91 changes: 75 additions & 16 deletions client/src/components/board/PermanentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 && (
<button
type="button"
className="absolute -left-2.5 -top-2.5 z-40 flex items-center justify-center rounded-full bg-black/90 leading-none text-amber-200 ring-2 ring-amber-200/80 shadow-[0_2px_8px_rgba(0,0,0,0.65)] transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
Expand All @@ -1115,8 +1174,8 @@ export const PermanentCard = memo(function PermanentCard({
height: attachmentBadgeSize,
fontSize: attachmentBadgeFontSize,
}}
title={t("permanent.viewAttachmentsFor", { count: 1, name: obj.name })}
aria-label={t("permanent.viewAttachmentsFor", { count: 1, name: obj.name })}
title={t("permanent.viewAttachmentsFor", { count: attachmentFanCardCount, name: obj.name })}
aria-label={t("permanent.viewAttachmentsFor", { count: attachmentFanCardCount, name: obj.name })}
onPointerDown={(event) => event.stopPropagation()}
onClick={openAttachmentFan}
>
Expand Down
Loading
Loading