From 4a89cb643427698734f9942c4a37ecfe74a4290c Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:46:55 +0200 Subject: [PATCH 1/8] fix(client): give a host's own click back to the host, and keep a fan route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A creature's activated ability was unreachable whenever an attached Aura or Equipment was activatable too. Reported for Slumbering Keepguard (`{2}{W}`); reproduces with Cooped Up, whose `{2}{W}: Exile enchanted creature` is legitimately activatable from the battlefield, so no engine defect is needed. During Priority `HumanResponseModel::ExactCandidates` publishes an attachment fan for every activatable attachment, so `viewerInteraction.attachmentFans` carries an entry for the host. The `attachmentsActionable` branch read that as "the host is not a legal choice" — an unchecked premise — and sat above the activation branch, so every click on the host opened the chooser instead. The chooser excludes the host by design (`id !== host.id`), so the host's ability had no path at all. The sibling branch that reads the affordance sets is placed last for exactly this reason and documents it; this one was not. Moved below the host's own target / activation / undo intent, which also makes the click ladder match the glow-ring priority ladder it advertises. Introduced by #6945 (a card PR), which is why the click order was never reviewed as such. Handing the click back to the host strands the attachments unless they keep a route of their own, and they had none in this state: `attachmentsActionable` is itself one of the disjuncts that expands the attachment stack, so no `+N` control renders, while the `⧉` control required exactly one attachment. Each Aura was then reachable only through a ~22px peek behind the host face. `⧉` now renders whenever attachments are present and the stack is expanded — the same predicate `+N` is derived from, so the two are complementary and exactly one route exists in every state that needs one. The `⧉` label counts what the fan will actually show (`interactionAttachmentFan?.children.length`), not `obj.attachments.length`: while an interaction is live the fan builds its cards from the projection, so counting the raw snapshot would promise more cards than appear, and would rediscover from the snapshot what the note above that line forbids. `permanent.viewAttachmentsFor` now interpolates a real count, which exposed a missing CLDR plural category: Polish selects `few`/`many` at 2/3/5 and neither key existed, so `pl` fell back to English. Added `_few`/`_many` in all seven catalogs (never selected outside `pl`, required by the `en`-oracle parity gate). `permanent.hiddenAttachmentsAria` carried the identical defect and is fixed in the same pass rather than left as the odd one out of a two-member class. The control's SIZE is deliberately unchanged. It 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 touch floor the branch above cites — but 44px is unreachable at this seam: battlefield cards sit in an 8px gap and `--card-base` floors at 3.5rem, so growing outward puts ~26px over the NEIGHBOUR's face at `z-40` and steals its clicks, while growing inward swallows most of a 56px card. Either would re-create the click theft this commit undoes. Recorded at the gate as a known limitation needing a layout-level answer, alongside the same gap on `+N`. Also resets `viewerInteraction` in the test suite's shared setup — without it the rows that publish a fan leak it into every later row, and the suite only passed because they were declared last. Co-Authored-By: Claude Opus 5 --- client/src/components/board/PermanentCard.tsx | 71 +++++- .../board/__tests__/PermanentCard.test.tsx | 236 ++++++++++++++++++ client/src/i18n/locales/de/game.json | 4 + client/src/i18n/locales/en/game.json | 4 + client/src/i18n/locales/es/game.json | 4 + client/src/i18n/locales/fr/game.json | 4 + client/src/i18n/locales/it/game.json | 4 + client/src/i18n/locales/pl/game.json | 8 +- client/src/i18n/locales/pt/game.json | 4 + 9 files changed, 328 insertions(+), 11 deletions(-) diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index 44930fbe14..66f5a115fa 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -517,6 +517,14 @@ 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. While + // an interaction is live `AttachmentFan` builds its cards from the projection's + // children, not from `obj.attachments`, so counting the raw snapshot here would + // promise more cards than the fan shows (an Aura + an Equipment where only the + // Equipment is a legal choice reads "2 attached cards" over a one-card fan) — + // and would rediscover from the snapshot exactly what the note above forbids. + const attachmentFanCardCount = + interactionAttachmentFan?.children.length ?? obj.attachments.length; const exileLinksExpanded = exileLinks.length <= 1 || isHovered || isSelected || isInspected; const visibleExileLinks = exileLinksExpanded ? exileLinks : exileLinks.slice(0, 1); const hiddenExileCount = exileLinks.length - visibleExileLinks.length; @@ -684,12 +692,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 +725,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 +1127,39 @@ 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). */} + {obj.attachments.length > 0 && attachmentsExpanded && (