fix(client): distinguish a permanent's mana abilities and render the unbounded-counter mark in the hover panel#6610
Conversation
…unbounded-counter mark in the hover panel Two display-layer defects observed on a real 4-player board (Kilo, Apogee Mind / Freed from the Real / Relic of Legends / Pentad Prism). BUG 1 - Relic of Legends' click prompt offered two byte-identical "Add one mana of any color" rows. abilityChoiceLabel synthesised the label from the produced mana alone and dropped the engine's per-ability description, so any permanent with two mana abilities of the same produced shape collapsed to one indistinguishable option. The engine already ships the discriminator: the CR 602.1a activation-cost text. Attach it as the option's description, gated on the engine's own CR 605.1a verdict (is_mana_ability) plus the presence of engine-authored text, so loyalty mana abilities keep their badge rendering and synthesized basic-land intrinsics stay byte-identical to today. BUG 2 - after an accepted counter-growth shortcut, the hover status box still showed the finite charge count while the battlefield pill and art-crop badge correctly showed the unbounded mark. CardInfoPanel was the one counter render site with no subscription to DerivedViews::unbounded_counters. Extract the twice-duplicated selector into useUnboundedCounterTypes and subscribe all three sites through it. No engine change: the finite count is deliberate (materialize_object_growth_shortcut is display-only) and already covered end-to-end from the same dump. Also substitutes the engine's ~ self-reference token (CR 201.5) on the paths this change makes user-visible: abilityChoiceLabel's non-mana tail, the hover activate list, and both blocked-ability read-outs. Deferred, each with a measured blocker recorded in the hook's doc: the attack-target picker needs a per-counter-type .every() intersection threaded through groupByName/AttackerStack (a representative-only lookup would render a false unbounded mark), and DialogAttachmentCard has no component test file. Assisted-by: ClaudeCode:claude-opus-5
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughUpdates card and ability displays to resolve ChangesAbility label formatting
Unbounded counter lookup
Card component rendering
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
matthewevans
left a comment
There was a problem hiding this comment.
Approved after current-head review: frontend and Rust CI are green; merge state is clean; CodeRabbit has no actionable findings.
🤖 AI text below 🤖
Summary
Fixes two display-layer defects observed on a real 4-player board (Kilo, Apogee Mind / Freed from the Real / Relic of Legends / Pentad Prism): Relic of Legends' click prompt offered two byte-identical "Add one mana of any color" rows, and the hover status box kept showing a finite counter count after an accepted counter-growth shortcut while the battlefield pill and art-crop badge correctly showed
∞. Both are frontend-only — the engine already ships every value needed, so zero Rust changes.BUG 1.
abilityChoiceLabelsynthesised the option label from the produced mana alone and dropped the engine's per-abilitydescription, so any permanent with two mana abilities of the same produced shape collapsed to one indistinguishable option. The engine already ships the discriminator: the CR 602.1a activation-cost text. It is now attached as the option'sdescription, gated on the engine's own CR 605.1a verdict (is_mana_ability === true) and the presence of engine-authored text.Both conjuncts are load-bearing and measured. Gating on
effect.type === "Mana"instead would regress loyalty mana abilities (Chandra, Torch of Defiance+1: Add {R}{R}) by rendering a cost line next to the+1LoyaltyBadge — exactly whatstripLoyaltyCostPrefixexists to suppress. Dropping&& ability.descriptionwould letformatCost's fallback leak the literal word"Activate"as secondary text (20 of 32AbilityCostvariants have no explicit arm), and would put an identical uninformative{T}under every option of a dual/shock/triome land, whose labels (Add {W}/Add {U}) already discriminate.BUG 2.
CardInfoPanelwas the one counter render site with no subscription toDerivedViews::unbounded_counters. The finite count is deliberate —materialize_object_growth_shortcutis documented display-only and is already covered end-to-end from this same dump bykilo_accept_marks_pentad_charge_as_unbounded_display_target. The twice-duplicated selector is extracted intouseUnboundedCounterTypesand all three sites subscribe through it (a third copy-paste was the thing to avoid).Also substitutes the engine's
~self-reference token (CR 201.5) on the paths this change makes user-visible:abilityChoiceLabel's non-mana tail, the hover activate list, and both blocked-ability read-outs. Without this, the same cost string would render substituted in the hover preview and raw in the choice modal — an inconsistency this PR would otherwise have introduced. Census over the reported dump: 22 of 293kind: "Activated"abilities leak~into cost text, 20 into effect text.Files changed
client/src/viewmodel/costLabel.ts— BUG 1 gate + cost line on all threeproducedsub-cases;~substitution on the non-mana tailclient/src/hooks/useUnboundedCounterTypes.ts(new) — the extracted∞-mark selectorclient/src/components/card/CardPreview.tsx— BUG 2 inCardInfoPanel;~in the activate list and the blocked-ability read-outclient/src/components/board/PermanentCard.tsx— migrated onto the hook;~in the blocked-ability tooltipclient/src/components/card/ArtCropCard.tsx— migrated onto the hookclient/src/utils/description.ts— corrected CR cite (201.4b → 201.5) and a doc block that claimed a word-boundary guard the code does not implementclient/src/viewmodel/__tests__/costLabel.test.ts,client/src/viewmodel/__tests__/manaAbilityOptionDiscrimination.test.ts(new),client/src/components/card/__tests__/CardPreview.test.tsx,client/src/components/board/__tests__/PermanentCard.test.tsx— testsTrack
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: /engine-implementer
CR references
No engine code changed; these are the rules cited in the frontend comments this PR adds or corrects. All eight verified by grep against
docs/MagicCompRules.txt.CR 201.5(:1322) —~means the object it is on; sub-casesCR 201.5a(:1324, granted) /CR 201.5b(:1327, gained)CR 602.1a(:2516) — the activation cost is everything before the colon; the discriminator this PR surfacesCR 605.1a(:2683) — mana-ability classification, incl. the "not a loyalty ability" exclusion the gate relies onCR 606.1(:2711) — loyalty abilitiesCR 732.2a(:6372) — shortcut proposal procedure, the origin of the∞markCR 701.34a(:3594) — proliferateCR 201.4b(:1310) — removed: it is split-card name choice and never described this helperVerification
All commands run in the PR's own worktree on the final rebased head
a0a6e24c4, afterpnpm install --frozen-lockfile(exit 0).pnpm exec tsc --noEmit— exit 0, no outputpnpm lint— exit 0,✖ 23 problems (0 errors, 23 warnings); identical to the pre-existing baseline, and zero warnings in any of the 10 touched filespnpm vitest run— exit 0,Test Files 267 passed | 4 skipped (271),Tests 2312 passed | 18 todo (2330), 0 failed./scripts/check-parser-combinators.sh <base>— Gate A PASS (see below); vacuous by construction, no file undercrates/engine/src/parser/changedgit diff --name-only | grep -c '^crates/'→ 0). Per repo policy, cargo is not run from a worktree while Tilt owns the main checkout's target lock.Test-count reconciliation. Base
438cad092measured 2318 tests. This PR adds 11 (8 planned + 3 from the review round). Upstream added 1 between438cad092and2c6596de9. 2318 + 11 + 1 = 2330. ✅Non-vacuity (the reason to trust the above). The two acceptance tests were written first and both bugs were reproduced at tip before any fix: 3 failing assertions with their negative controls passing — the controls passing is what makes them discriminating. Every behavioural claim in this PR was independently revert-probed by a reviewer who did not write the code: reverting each edit alone turns its own test red, and each failure was checked to be attributable to that edit rather than to an upstream conjunct. Measured examples:
description: costLineexpected 'Add one mana of any color' not to be 'Add one mana of any color'is_mana_ability === trueconjunct'+1'&& ability.descriptionconjunct'Activate'∞ternary inCardInfoPanelexpected 'Artifactcharge: 2' to contain 'charge: ∞'isUnboundedprop only∞badge still renders, and the tooltip test alone fails — proving the prop, not the glyph, is that test's discriminator~substitution on the non-mana tailexpected '{T}, Sacrifice ~' to be '{T}, Sacrifice Ghost Quarter'Fixtures are engine-serialized values copied verbatim from the reported dump (
objects["402"]Relic of Legends,["409"]Pentad Prism,["110"]Ghost Quarter,["7"]Hawkeye), not hand-authored approximations.Gate A
Gate A PASS head=a0a6e24c4ce6521892747da658a21e3b71c0d712 base=2c6596de9360ef3e8d5df1c20e863631201253aa
Anchored on
client/src/viewmodel/costLabel.ts:356,373,393,403— four sibling branches of this same function already return{ label, description }with the CR-annotated secondary line; BUG 1 adds a fifth case to that established shape rather than a new convention.client/src/components/board/PermanentCard.tsx:941andclient/src/components/card/ArtCropCard.tsx:216— the two render sites that already read the engine'sunbounded_counterschannel and passisUnboundedtoCounterTooltip; BUG 2 makesCardInfoPanelthe third consumer of that exact pattern, via the selector extracted from these two.Final review-impl
Final review-impl PASS head=a0a6e24c4ce6521892747da658a21e3b71c0d712
Disclosure of exactly what ran when:
/review-implround 2 returned READY TO COMMIT (0 blocker / 0 high / 0 med, 3 low) against the pre-commit tree. Two of the three lows were comment-accuracy defects and were fixed; those fixes were then independently verified by a separate agent that re-derived every census figure itself and confirmed the diff was comment-only. The rebase onto2c6596de9touched no line of this PR (upstream'sCardPreview.tsxedit is inCardImagePreview, disjoint from all four edit regions), and all three gates were re-run green afterwards on this head.Claimed parse impact
None.
Scope Expansion
The review found that
abilityChoiceLabel's non-mana tail and two blocked-ability read-outs leaked a raw~. These were folded in rather than deferred: they are the same defect class, live in files already in scope, andGamePage.tsxwas verified as the only production caller ofabilityChoiceLabel, so a fix in the shared function is the root-cause fix rather than a per-caller guard. Leaving them would have shipped a self-inflicted inconsistency, since this PR is what makes the substituted form visible alongside the raw one.Deliberately not in scope, each with a measured blocker:
components/controls/AttackTargetPicker— a chip stands for N grouped objects while the mark is per object id, so it needs a per-counter-type.every()intersection threaded throughgroupByName/AttackerStack(mirroringisUnboundedPile). A representative-only lookup would render a false∞, which is strictly worse than today's missing one — the fail-safe directionbattlefieldProps.tsalready documents in-code.components/hud/DialogAttachmentCard— no component test file exists, so a change there could not be made discriminating.OptionalEffectModal/TriggerOrderModal/ReplacementModalrender enginedescriptionraw (51replacement_definitions+ 13static_definitionsin the dump carry~). DifferentWaitingForchannel, different files, each needing its own fixture.cost_text, so the frontend stops splitting Oracle text on":"at all.Disclosed residual. The
&& ability.descriptionconjunct means two descriptionless mana abilities sharing a produced shape still collapse. Measured, not assumed: the engine has six non-parser mana-ability constructors, and two pairs qualify — Treasure + Gold and Spawn + Powerstone (game/effects/token.rs), reachable becauseapply_token_ability_payloadextendsobj.abilitieswithout dedup. Those shapes collapse identically today, so this is not a regression; dropping the conjunct would fix them at the cost of the dual-land and"Activate"regressions above. The right fix is engine-side (F4: give those four constructors a.description(…); their CR 111.10 doc comments already hold the text), subsumed by F1.Validation Failures
None.
CI Failures
None.
Summary by CodeRabbit
Bug Fixes
∞consistently in cards, previews, and tooltips.~with the relevant card or source name.Tests