Skip to content

fix(client): one authority for object activation, and make the attachment fan use it - #6992

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:fix/attachment-fan-priority-activation
Aug 4, 2026
Merged

fix(client): one authority for object activation, and make the attachment fan use it#6992
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:fix/attachment-fan-priority-activation

Conversation

@lgray

@lgray lgray commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

An attached permanent's own activated abilities were unreachable. With Kilo, Apogee Mind enchanted by Freed from the Real the engine published legalActionsByObject["408"] = [#0, #1], and the attachment fan was inert — no ring, and clicking the Aura did nothing. Root cause is that four call sites each re-derived "may I activate this object, and what does a click do" from a different premise; this extracts the two authorities into viewmodel/cardActionChoice.ts, adopts them at every deciding site, and gives the fan and the host click a path to them.

Three of the adopting sites were measurably wrong before the change: the attachments dialog, the command-zone emblem chip, and the command-zone commander chip gated on a raw <x>Actions.length > 0 bucket check with neither a WaitingFor gate nor a seat gate, so an opponent's emblem/commander chip was clickable from this viewer's seat and the attachments dialog stayed interactive at DeclareBlockers.

Review round (head 5ea5dc100)

Five follow-up fixes from the independent /review-impl (GO, 3 findings, 0 blocking) and from @matthewevans' CHANGES_REQUESTED:

  1. Exhaustive switch + never at all four resolveObjectActivation consumers. Only AttachmentFan's bare else failed to compile against a new union variant; PermanentCard, DialogAttachmentCard and CommandZone compiled clean and silently dropped it. Today's kind: "none" behaviour is preserved exactly (it is reachable only through the render→click staleness window, where doing nothing is right) — this makes a future variant a compile error, per CLAUDE.md "exhaustive match without wildcard fallbacks".
  2. The authority was receiving half its own gate. deriveActivationAffordances emits two bits; resolveObjectActivation took only the mana one, so the non-mana partition was merged unconditionally. Measured: [P4 ManaPayment] activatableSet=false manaSet=true clickOffered=true verdict=choose[NON-mana, mana] — byte-identical to the [P4 Priority] arm, at a state where the gate had already closed the non-mana ring (CR 113.3b). It now takes the whole ActivationAffordances plus the object id and drops the non-mana partition when that ring is closed, mirroring the existing mana drop. Taking the pair rather than two loose booleans makes "half the gate" unrepresentable at a call site, which is the actual defect class.
  3. Same-mechanism sibling swept: CommanderCardZone. It still gated canCast/canNinjutsu on the raw bucket with neither a WaitingFor nor a seat gate — the exact predicate this PR replaced at CommandZone.EmblemCard, rendered from the same PlayerArea → CommandDock subtree for every seat. Measured at the un-fixed site: [P3 opponent commander @ opponent priority, viewer=P0] renderedAsButton=true dispatches=1 | sharedAuthorityWouldSay activatable=false, with an own-seat control reporting activatable=true. Pre-existing rather than introduced by this PR, and swept here because it is the same mechanism.
  4. Grouped-emblem activation identity@matthewevans' [MED] finding, independently reported by CodeRabbit. GroupedEmblem kept a lone representative plus a count, and legalActionsByObject is keyed by exact object id, so an action the engine published against a non-representative member was unreachable and the chip rendered inert. It now retains every member; the chip acts on the member the shared affordance sets name, and the count badge is that list's length so it cannot drift from the actionable ids. This changes which id the authority is asked about, not who decides — no second decision site, and it is class-general for any group size and any member position. (His evidence cites client/src/components/game/CommandZone.tsx; that path does not exist — find client/src -name "CommandZone*.tsx" returns only client/src/components/zone/CommandZone.tsx. His line numbers and substance match the real file, so we read it as components/zone/ and fixed it there.)
  5. A pre-existing vacuous fixture that fix 2 unmasked. PermanentCard.test.tsx > opens the ability picker when a land has multiple mana abilities seeded manaTappableObjectIds={40} with activatableObjectIds={} but omitted is_mana_ability on both abilities, so the resolver classified them NON-mana — a state the engine cannot emit, since the deriver and the resolver classify through the same isManaObjectAction. The row only passed because the old code never consulted the activation bit. Adding the flags makes it actually exercise the mana partition.

Files changed

  • client/src/viewmodel/cardActionChoice.ts — the two authorities: deriveActivationAffordances (timing + seat gate → the activatable / mana-tappable id pair) and resolveObjectActivation (typed none / dispatch / choose verdict; owns the CR 605.1a mana partition, the CR 113.3b non-mana gate, and the Festering Thicket — [[Festering Thicket]] decided it would cycle itself instead of letting me choose whether to play it… #506 confirmation gate)
  • client/src/components/board/GameBoard.tsx — derives the affordance pair from the authority instead of an inline copy
  • client/src/components/board/AttachmentFan.tsx — mode 2: the fan as a reachability surface for the permanent's own legal actions; mode 1 (engine interaction) unchanged and still returns early; exhaustive verdict handling
  • client/src/components/board/PermanentCard.tsx — adopts the authority; adds the last-placed host-click fall-through to the fan; exhaustive verdict handling
  • client/src/components/hud/DialogAttachmentCard.tsx — adopts the shared gate (gains the missing timing + seat gates); exhaustive verdict handling
  • client/src/components/zone/CommandZone.tsx — same, for the emblem chip; plus grouped-emblem activation identity (members: GameObject[] replaces count + representative)
  • client/src/components/zone/CommanderCardZone.tsxswept sibling: adopts deriveActivationAffordances for canCast / canNinjutsu
  • client/src/components/zone/LibraryPile.tsx — refactor only
  • client/src/viewmodel/__tests__/cardActionChoice.test.ts — authority-level rows, incl. the closed-ring rows and a hostile fixture proving membership is keyed by the id passed in, not by set emptiness
  • client/src/components/board/__tests__/GameBoardInteractionWiring.test.tsx (new) — real <GameBoard/> + real provider, read back from a child consumer
  • client/src/components/board/__tests__/AttachmentFan.test.tsx (new) — mode 2, incl. a multi-authority hostile fixture
  • client/src/components/board/__tests__/abilityChoiceConsumerWiring.test.tsx (new) — producer → store → real DialogHost consumer
  • client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx (new) — timing + seat arms
  • client/src/components/zone/__tests__/CommandZone.test.tsx (new) — timing + seat + order arms, plus the grouped-emblem identity block (non-representative live; chooser id; representative-live control; all-live single-dispatch control; none-live negative control)
  • client/src/components/zone/__tests__/CommanderCardZone.test.tsx — seat and timing arms for the swept sibling
  • client/src/components/board/__tests__/PermanentCard.test.tsx — host-click branch-order rows; the mana-ability fixture repair above
  • client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx — turns a previously silent activatableObjectIds read red

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: not-applicable — frontend-only change; no crates/engine/ game logic is touched (17 files, all under client/src/).

CR references

CR 113.3b (a non-mana activated ability may be activated only when its controller has priority), CR 114, CR 114.1 (an emblem is a marker representing its own object), CR 114.4 (abilities of emblems function in the command zone), CR 118.12a, CR 301.5, CR 303.4 (an attachment is its own object), CR 602.1, CR 605.1a (mana-ability partition). Every number added in the diff was grepped against docs/MagicCompRules.txt: 0 UNVERIFIED, and the instrument's negative control (702.808) correctly reports absent, so that zero is not vacuous.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is clean for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

All results below are from the pushed head 5ea5dc100 (built on 38c0dc1ae in a detached worktree, since this checkout hosts a concurrent agent's uncommitted crates/** work).

  • npx tsc -b --noEmit --forceEXIT=0, 0 error lines. Instrument control: with a TS2322 injected, the repo's vacuous tsc --noEmit -p tsconfig.json still reports EXIT=0 errorLines=0 while -b --noEmit --force reports EXIT=2 errorLines=2.
  • npx eslint .EXIT=0, ✖ 27 problems (0 errors, 27 warnings). eslint . cannot gate react-hooks/exhaustive-deps (severity warn), so the gate is the warning set: onlyInBASE=0 onlyInPOST=0, exhaustive-deps 2 on both arms. Instrument positive control for that zero-diff: dropping objects from CommanderCardZone's new memo dep array keeps EXIT=0 but moves the set to 28 warnings / 3 exhaustive-deps, with the added line naming CommanderCardZone.tsx.
  • npx vitest run (full configured suite) — EXIT=0, Test Files 285 passed | 3 skipped (288), Tests 2518 passed | 12 todo (2530).
  • Test-count attribution, corrected. The earlier body said "42 new it(" and that figure was wrong twice over. Measured on this branch: merge base a970e5548Test Files 280 passed | 3 skipped (283) / Tests 2465 passed | 12 todo (2477); the previous head 38c0dc1ae288 / 2520 (i.e. +43, not 42); this head → 288 / 2530. The PR's true delta is +5 files and +53 tests, −0 removed (2530 − 2477 = 53). A second, independent instrument agrees: a structural grep census of ^\s*(it|test)(\.(only|skip|todo|concurrent))?\( over client/src/**/*.test.ts{,x} gives 2249 at the merge base and 2302 here — delta +53. (The absolute counts differ from vitest's because vitest also counts .each/dynamic rows; the delta is the claim and both instruments give 53.)
  • Two-sided controls for the review round, each flipping its own named assertion rather than a suite delta:
    • fix 1 — ARM A (synthetic 4th ObjectActivation variant, consumers unfixed): EXIT=2, exactly 1 error, AttachmentFan.tsx(176,69) TS2339. ARM B (4th variant + the new switches): EXIT=2, exactly 4 errors, TS2322 … not assignable to type 'never' at AttachmentFan(192,17), PermanentCard(715,17), DialogAttachmentCard(163,17), CommandZone(174,15). ARM C (real 3-variant union + switches): EXIT=0; the synthetic variant is grep-verified removed (0 hits).
    • fix 2 — DROP (if (true) for the activation ring) fails the 3 new authority rows, first flip expected { kind: 'choose', …(1) } to deeply equal { kind: 'none' }; TRIVIALIZE (if (false)) fails 10 rows including every positive control.
    • fix 3 — DROP (restore the raw-bucket predicates) fails both new rows, first flip expected "vi.fn()" to not be called at all, but actually been called 1 times; TRIVIALIZE (both flags constant-false) fails 5 rows.
    • fix 4 — DROP (representative-only on both the gate and the dispatch axis, i.e. the pre-fix code) fails 2 rows: expected false to be true on the chip's own data-activatable, and expected null to deeply equal { objectId: 701, actions: [ …(2) ] } on the chooser id; TRIVIALIZE ("always the last member") fails 5 rows — both controls, the negative control, and the timing + seat rows.
  • Original round: 17 mutants, each flipping its own named assertion — hunk-B drop / constant-true / mana-disjunct-drop / toggle-selection / branch-reorder / raw-bucket; the overflow read's drop vs count-everything (verified to fail by different mechanisms — count-everything renders act 9, drop renders no badge); the fan's mode-2 drop, never-close, host-invariant drop, early-return drop, never-selectable; and both gates forced constant-true and constant-false.

Gate A

Gate A PASS head=5ea5dc100cb04e7eff820f8026e8ef5d2fba8f66 base=4b34e5465eafa94bcd49dfe1a9275968be4300dc

Anchored on

  • client/src/viewmodel/cardActionChoice.ts:73resolveSingleActionDispatch, the existing single-authority-for-a-click pattern this extends rather than replaces
  • client/src/components/board/PermanentCard.tsx:688 — the existing isActivatable branch that already routed through the shared authority; the new sites adopt exactly this shape

Final review-impl

Final review-impl PASS head=38c0dc1aeb23420604ced0b13431dbd3270f47cd — an independent /review-impl returned GO, 3 findings, 0 blocking at that head. Head 5ea5dc100 is the fix round for those findings plus @matthewevans' CHANGES_REQUESTED; no independent review has run at 5ea5dc100 yet, and the evidence for it is the gate battery and the two-sided controls above.

Claimed parse impact

None.

Scope Expansion

The swept sibling client/src/components/zone/CommanderCardZone.tsx (fix 3) is pre-existing, not introduced by this PR. It is included because it is the same mechanism as the CommandZone.EmblemCard hunk already here, and leaving one copy of a defect the PR is removing everywhere else would be worse than the extra 30 lines.

Named follow-ups (deliberately not fixed here)

  1. Five components each memoize the identical pure deriveActivationAffordances callGameBoard, AttachmentFan, DialogAttachmentCard, CommandZone.EmblemCard, CommanderCardZone.CommanderCard — rather than sharing a useActivationAffordances() hook, and the last two run per chip / per commander, not once per component. "One authority" in code, several computations at runtime. Rated LOW: it is a shape/perf refactor with no correctness content, and folding it in would widen this diff across five files for no behavioural change.
  2. Engine-side reachability of fix 2 is UNMEASURED. Whether a payment-state bucket can actually carry a non-mana ActivateAbility needs crates/** plus a cargo run, and the cargo token belongs to a concurrent agent on this checkout. The fix is fail-closed either way: if the engine never emits that shape, fix 2 is a no-op; if it does, fix 2 stops the UI offering what the board refuses.

Validation Failures

The original round's /review-impl was a self-review; that has since been superseded by an independent /review-impl at 38c0dc1ae (GO, 3 findings, 0 blocking) and by @matthewevans' CHANGES_REQUESTED. All four findings are fixed at 5ea5dc100. The self-review's own two findings (a negative assertion with no reach guard, and an untested arm of the host-click branch on mobile viewports) were fixed before 38c0dc1ae.

Browser verification was not performed; the evidence is the component-level suite plus the mutant controls above.

Not fixed, because it was measured unreachable — CodeRabbit's first inline comment (AttachmentFan.tsx:222, Minor): "during an engine interaction selectable ignores viewerInteraction.canSubmit, so an unsubmittable card still paints the pick affordance while the click does nothing." The premise is that can_submit: false can co-occur with a populated attachment_fans. It cannot, in the engine as written:

  • derive_viewer_interaction (crates/engine/src/game/interaction.rs:7273-7436) is the sole producer. Repo-wide grep -rn "ViewerInteraction {" crates/ returns 10 hits: 7 struct literals, all inside that one function, plus the struct definition and two return-type annotations in tests/integration/interaction_contract.rs.
  • Scripted census over those 7 literals: VIOLATIONS(can_submit=false AND populated attachment_fans) = 0. Both can_submit: false literals (:7286, :7302) pair with attachment_fans: BTreeMap::new() (:7289, :7305). The only populated construction (:7425, the field-init shorthand bound at :7373 and filled at :7392) sets can_submit: true. The one post-construction mutation, view.attachment_fans.clear() at :7430, is the payload-too-large path and never sets can_submit: false.
  • Positive control for that zero (a zero census is worthless without one): injecting a synthetic populated map into the first can_submit: false literal makes the same census report VIOLATIONS = 1. The instrument is not stuck at zero.
  • The engine file is byte-unchanged by this PR.

So the affordance and the click already agree on every reachable state, and CodeRabbit's proposed diff would guard against a state the producer cannot emit. If this is ever tidied, the architecturally correct move is the inverse of that diff — delete the now-redundant canSubmit re-check inside handlePick, since a paint/click asymmetry that cannot occur is the smell, not the paint. Recorded as a named follow-up, deliberately not taken while a maintainer CHANGES_REQUESTED is open: adding an unrequested cosmetic change to this diff at this moment is scope creep.

CI Failures

None.

Summary by CodeRabbit

  • New Features

    • Improved activation interactions for permanents, attachments, emblems, and other cards.
    • Cards are now selectable only when a legal action is available.
    • Direct actions execute immediately, while multiple available abilities open a choice dialog.
    • Added support for activating attachments through their host card, including mobile interactions.
  • Bug Fixes

    • Prevented activation during unavailable game phases or when another player has priority.
    • Improved handling of mana abilities and source-consuming actions.

lgray added 2 commits August 4, 2026 07:18
… deciding sites

Four call sites each re-derived "may I activate this object, and what does a
click do" from a different premise, so they disagreed. The attachments dialog
and the command-zone emblem chip gated on a raw `<x>Actions.length > 0` bucket
check with NEITHER a `WaitingFor` gate NOR a seat gate: an opponent's emblem
chip was clickable from this viewer's seat, and the attachments dialog stayed
interactive at DeclareBlockers.

`viewmodel/cardActionChoice.ts` gains the two authorities:

- `deriveActivationAffordances(waitingFor, canAct, legalActionsByObject, objects)`
  returns the `activatableObjectIds` / `manaTappableObjectIds` pair, applying the
  timing and seat gates once.
- `resolveObjectActivation(actions, object, canTapForMana)` returns a typed
  `ObjectActivation` verdict — `none` / `dispatch` / `choose` — and owns the
  CR 605.1a mana/non-mana partition and the phase-rs#506 confirmation gate, so no call
  site inspects an action list again.

`GameBoard` now derives the pair from that authority instead of an inline copy
and publishes it unchanged on `BoardInteractionContext`. `PermanentCard`,
`DialogAttachmentCard` and `CommandZone` consume it; `LibraryPile` is
refactor-only.

Tests. The authority itself is covered at the unit level (V14/V15/V18/V18b/
V19/V19b-V19e/V20c2/V20d). `GameBoardInteractionWiring.test.tsx` renders the
real `<GameBoard/>` and reads both sets back out of the real provider from a
child consumer, asserting CONTENTS rather than sizes — a parity test alone
could not have caught an unwired provider. The two newly gated call sites get
their own timing and seat arms. `BattlefieldZoneOverflow` is the second
consumer of the affordance pair and its `activatableObjectIds` read was
previously silent: deleting it left all 130 board tests green, so a row that
turns it red ships with it.

Assisted-by: ClaudeCode:claude-opus-5
…thority

THE BUG: with Kilo, Apogee Mind (401) enchanted by Freed from the Real (408),
the engine published `legalActionsByObject["408"] = [#0, phase-rs#1]` and the fan was
inert — no ring, and clicking Freed did nothing. The fan had exactly one
source, an open interaction's projection, so a populated action bucket with no
prompt open reached nothing at all. Freed's own `{U}: Untap` was unreachable.

`AttachmentFan` gains a second mode. Mode 1 (an engine interaction owns the
prompt) is byte-unchanged and still returns early, so exactly one authority can
win. Mode 2 runs only when no prompt is open: the ring and the click both come
from the shared authority the battlefield already uses, so the fan can never
offer what the board would not. The fan is closed BEFORE the chooser opens —
it is a `fixed inset-0 z-[120]` backdrop with an `onClick` catcher and
`DialogHost` anchors at z-40, so a fan left mounted would paint over the modal
it just opened and swallow its clicks. The host card is the fan's anchor and is
never one of its picks, even when the engine publishes actions for the host too.

`PermanentCard` gains the matching host click (hunk B). An attached
Aura/Equipment/Fortification is its own object (CR 301.5 / CR 303.4), but its
only in-place affordance is a ~22px peek rendered BELOW the host, under the
44px touch-target floor. When the host itself offers nothing and an attachment
does, the click falls through to the full-card chooser. The branch is placed
LAST so it can never pre-empt the host's own target / activation / undo intent,
and reads the same affordance sets as the host's own ring rather than the raw
bucket. Its selection is UNCONDITIONAL, deliberately unlike the plain-click
fallback which toggles: a toggle would strand the fan open over a host that
just lost its ring and its attachment expansion.

Tests. `AttachmentFan.test.tsx` covers mode 2 including a multi-authority
hostile fixture where a prompt and a bucket are live at once.
`abilityChoiceConsumerWiring.test.tsx` drives the producer and observes the
real consumer — `DialogHost`'s overlay — so the store hand-off is measured
rather than assumed; both its assertions are `expect.soft` so neither can
pre-empt the other, and its third arm exists because the first two are blind
to an always-true `selectable`. `PermanentCard.test.tsx` pins the branch
ORDER with the actionable attachment held fixed across every arm.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray requested a review from matthewevans as a code owner August 4, 2026 12:21
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f1af4ec-8c19-4494-835f-c7778fe67574

📥 Commits

Reviewing files that changed from the base of the PR and between 38c0dc1 and 5ea5dc1.

📒 Files selected for processing (10)
  • client/src/components/board/AttachmentFan.tsx
  • client/src/components/board/PermanentCard.tsx
  • client/src/components/board/__tests__/PermanentCard.test.tsx
  • client/src/components/hud/DialogAttachmentCard.tsx
  • client/src/components/zone/CommandZone.tsx
  • client/src/components/zone/CommanderCardZone.tsx
  • client/src/components/zone/__tests__/CommandZone.test.tsx
  • client/src/components/zone/__tests__/CommanderCardZone.test.tsx
  • client/src/viewmodel/__tests__/cardActionChoice.test.ts
  • client/src/viewmodel/cardActionChoice.ts

📝 Walkthrough

Walkthrough

The PR centralizes activation affordance derivation and action resolution. Board, attachment, HUD, and zone components now use engine-authorized actions to gate activation, dispatch direct actions, or open ability-choice UI. Tests cover timing, permissions, action ordering, grouping, and interaction wiring.

Changes

Activation authority and interaction consumers

Layer / File(s) Summary
Shared activation derivation and resolution
client/src/viewmodel/cardActionChoice.ts, client/src/viewmodel/__tests__/cardActionChoice.test.ts
Adds shared affordance derivation and activation resolution. Tests cover waiting states, permissions, mana actions, consuming abilities, ordering, and stale objects.
Board affordance wiring
client/src/components/board/GameBoard.tsx, client/src/components/board/__tests__/GameBoardInteractionWiring.test.tsx, client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx
GameBoard publishes centralized activation and mana-tapping IDs. Tests verify seat gating and badge counts.
Board activation and attachment flow
client/src/components/board/AttachmentFan.tsx, client/src/components/board/PermanentCard.tsx, client/src/components/board/__tests__/*
Board interactions support direct activation, ability choices, interaction submission forwarding, and actionable attachment routing.
HUD and zone activation consumers
client/src/components/hud/*, client/src/components/zone/*, client/src/components/hud/__tests__/*, client/src/components/zone/__tests__/*
HUD and zone components use shared gates and resolution outcomes. Tests cover priority, seat eligibility, dispatch, dismissal, grouping, and action order.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: quality

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: centralizing object activation and applying it to the attachment fan.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@lgray
lgray marked this pull request as draft August 4, 2026 12:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
client/src/viewmodel/cardActionChoice.ts (1)

160-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the two map iterations and the repeated seat conjunction.

canActForWaitingState is repeated in all four arms of playerCanAct, and legalActionsByObject is enumerated twice. One pass over the map with a typed set of enabling states reads closer to the codebase's "one authority, one derivation" shape, and it halves the work for every consumer that calls this per render.

♻️ Proposed single-pass derivation
-  const playerCanAct =
-    waitingFor != null
-    && (
-      (waitingFor.type === "Priority" && canActForWaitingState)
-      || (waitingFor.type === "ManaPayment" && canActForWaitingState)
-      || (waitingFor.type === "UnlessPayment" && canActForWaitingState)
-      // CR 118.12a: Disjunctive unless-cost — same input enablement as
-      // UnlessPayment (player chooses among sub-costs).
-      || (waitingFor.type === "UnlessPaymentChooseCost" && canActForWaitingState)
-    );
-
-  if (waitingFor?.type === "Priority" && canActForWaitingState) {
-    for (const [idStr, actions] of Object.entries(legalActionsByObject)) {
-      const objectId = Number(idStr);
-      const object = objects[objectId];
-      if (!object) continue;
-      if (actions.some((action) => !isManaObjectAction(action, object))) {
-        activatableObjectIds.add(objectId);
-      }
-    }
-  }
-
-  if (playerCanAct) {
-    for (const [idStr, actions] of Object.entries(legalActionsByObject)) {
-      const objectId = Number(idStr);
-      const object = objects[objectId];
-      if (!object) continue;
-      if (actions.some((action) => isManaObjectAction(action, object))) {
-        manaTappableObjectIds.add(objectId);
-      }
-    }
-  }
+  // CR 113.3b: non-mana activations need priority. CR 118.12a: a disjunctive
+  // unless-cost enables the same mana input as a plain UnlessPayment.
+  const atPriority = canActForWaitingState && waitingFor?.type === "Priority";
+  const manaRingOpen =
+    canActForWaitingState
+    && (waitingFor?.type === "Priority"
+      || waitingFor?.type === "ManaPayment"
+      || waitingFor?.type === "UnlessPayment"
+      || waitingFor?.type === "UnlessPaymentChooseCost");
+
+  if (atPriority || manaRingOpen) {
+    for (const [idStr, actions] of Object.entries(legalActionsByObject)) {
+      const objectId = Number(idStr);
+      const object = objects[objectId];
+      if (!object) continue;
+      for (const action of actions) {
+        if (isManaObjectAction(action, object)) {
+          if (manaRingOpen) manaTappableObjectIds.add(objectId);
+        } else if (atPriority) {
+          activatableObjectIds.add(objectId);
+        }
+      }
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/viewmodel/cardActionChoice.ts` around lines 160 - 199, Refactor
the derivation around playerCanAct and the
activatableObjectIds/manaTappableObjectIds updates to evaluate
canActForWaitingState once and iterate legalActionsByObject only once. Use a
typed set of waiting-state types to determine whether the current state enables
actions, then classify each object’s actions into the existing activatable and
mana sets while preserving the Priority-only restriction for activatable
indicators.
client/src/components/board/AttachmentFan.tsx (1)

162-168: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Activation resolves from two different snapshots. Both click handlers read the action bucket live from useGameStore.getState() but take the canTapForMana argument and the entry gate from a render-time value. If an engine snapshot commits between the last render and the click, the resolver partitions a new bucket against an old timing verdict. The mana branch then can drop a legal mana action, or admit one at a state the mana ring no longer covers, and resolveObjectActivation can return none so the click does nothing.

  • client/src/components/board/AttachmentFan.tsx#L162-L168: derive the affordances from the same getState() snapshot used for the bucket, then pass that snapshot's manaTappableObjectIds.has(id) into resolveObjectActivation and re-check the gate against it.
  • client/src/components/board/PermanentCard.tsx#L689-L702: replace the context-derived canTapForMana with the value derived from the same getState() snapshot that supplies the bucket, so the entry gate and the partition agree.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/board/AttachmentFan.tsx` around lines 162 - 168, Use
one live game-store snapshot for activation decisions: in AttachmentFan.tsx at
lines 162-168, derive affordances from the same getState() result used for
legalActionsByObject, re-check the entry gate against that snapshot, and pass
its manaTappableObjectIds value to resolveObjectActivation; in PermanentCard.tsx
at lines 689-702, replace the context-derived canTapForMana with the value from
the same getState() snapshot supplying the action bucket so the gate and
partition agree.
client/src/components/board/__tests__/PermanentCard.test.tsx (1)

121-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the six positional affordance sets with an options object.

renderPermanent now takes six defaulted Set parameters. Call sites read as renderPermanent(new Set(), new Set(), new Set(), new Set(), new Set(), new Set([2])) at line 562. A wrong slot seeds the wrong affordance and the arm still passes, which defeats the mutation-visibility intent stated in the header comments.

♻️ Proposed signature
-function renderPermanent(
-  validTargetObjectIds = new Set<number>(),
-  selectableSacrificeObjectIds = new Set<number>(),
-  boardChoiceObjectIds = new Set<number>(),
-  activatableObjectIds = new Set<number>(),
-  undoableTapObjectIds = new Set<number>(),
-  manaTappableObjectIds = new Set<number>(),
-) {
+function renderPermanent(overrides: {
+  validTargetObjectIds?: Set<number>;
+  selectableSacrificeObjectIds?: Set<number>;
+  boardChoiceObjectIds?: Set<number>;
+  activatableObjectIds?: Set<number>;
+  undoableTapObjectIds?: Set<number>;
+  manaTappableObjectIds?: Set<number>;
+} = {}) {
+  const {
+    validTargetObjectIds = new Set<number>(),
+    selectableSacrificeObjectIds = new Set<number>(),
+    boardChoiceObjectIds = new Set<number>(),
+    activatableObjectIds = new Set<number>(),
+    undoableTapObjectIds = new Set<number>(),
+    manaTappableObjectIds = new Set<number>(),
+  } = overrides;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/board/__tests__/PermanentCard.test.tsx` around lines
121 - 127, Update renderPermanent to accept a single options object containing
named affordance Set properties instead of six positional parameters, preserving
defaults for omitted properties. Migrate every renderPermanent call site,
especially the mana-tappable case near the reported call, to use the
corresponding property name so arguments cannot seed the wrong affordance.
client/src/viewmodel/__tests__/cardActionChoice.test.ts (1)

561-572: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a seat-gate arm for a payment state.

The seat axis is asserted at Priority only. deriveActivationAffordances applies canActForWaitingState separately in the mana branch. A change that drops the seat gate from the ManaPayment/UnlessPayment arms leaves every row in this file green, so an opponent's land would ring for this viewer during their mana payment with no failing test.

💚 Proposed added arm
   it("offers nothing at Priority when the viewer cannot act for the waiting state", () => {
     const derived = deriveActivationAffordances(
       PRIORITY,
       false,
       legalActionsByObject,
       objects,
     );
     expect(sorted(derived.activatableObjectIds)).toEqual([]);
     expect(sorted(derived.manaTappableObjectIds)).toEqual([]);
   });
+
+  // The seat gate on the MANA branch, independent of the Priority branch above.
+  it("offers no mana ring at ManaPayment when the viewer cannot act", () => {
+    const derived = deriveActivationAffordances(
+      MANA_PAYMENT,
+      false,
+      legalActionsByObject,
+      objects,
+    );
+    expect(sorted(derived.activatableObjectIds)).toEqual([]);
+    expect(sorted(derived.manaTappableObjectIds)).toEqual([]);
+  });

As per path instructions: use targeted fixtures that cover timing/seat gates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/viewmodel/__tests__/cardActionChoice.test.ts` around lines 561 -
572, Add a test case similar to the existing "offers nothing at Priority when
the viewer cannot act for the waiting state" test, but for a payment state such
as ManaPayment or UnlessPayment. Call deriveActivationAffordances with a payment
state constant instead of PRIORITY, set canActForWaitingState to false, and
verify that both activatableObjectIds and manaTappableObjectIds are empty. This
ensures the seat-gate is enforced in the mana branch and prevents opponent lands
from being tappable when the viewer cannot act during payment states.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/components/board/AttachmentFan.tsx`:
- Line 222: Update the selectable condition in AttachmentFan so interaction
cards are selectable only when the current interaction can submit, alongside the
existing non-host and activation checks. Keep the host exclusion and
non-interaction canActivate behavior unchanged, ensuring visual pick affordances
match handlePick’s canSubmit guard.

In `@client/src/components/hud/DialogAttachmentCard.tsx`:
- Around line 149-153: The outcome handling for resolveObjectActivation uses
partial if-else chains that only handle two of three possible outcomes (dispatch
and choose), leaving the none outcome unhandled and allowing new outcomes to
silently fall through without error. At
client/src/components/hud/DialogAttachmentCard.tsx lines 149-153, replace the
if-else chain with an exhaustive switch statement on verdict.kind that
explicitly handles all three cases: none, dispatch, and choose, and add a never
assertion in a default case to catch any unhandled outcome. Apply the same
exhaustive switch pattern at client/src/components/zone/CommandZone.tsx lines
158-162 to ensure both consumers handle every ObjectActivation outcome variant
explicitly and fail at type-check time if a new outcome is added.

In `@client/src/components/zone/CommandZone.tsx`:
- Around line 146-163: Preserve each grouped emblem’s source identity in
GroupedEmblem instead of resolving activation solely through the
representative.id used by CommandZone. Update handleActivate and the related
activatability checks to inspect every member’s original ID and emblem,
selecting or resolving the available legal action while retaining the existing
dispatch and ability-choice behavior. Add a fixture covering a group where only
a nonrepresentative member has a legal action, and maintain strict CR fidelity
using reusable grouped-emblem logic rather than a card-specific exception.

---

Nitpick comments:
In `@client/src/components/board/__tests__/PermanentCard.test.tsx`:
- Around line 121-127: Update renderPermanent to accept a single options object
containing named affordance Set properties instead of six positional parameters,
preserving defaults for omitted properties. Migrate every renderPermanent call
site, especially the mana-tappable case near the reported call, to use the
corresponding property name so arguments cannot seed the wrong affordance.

In `@client/src/components/board/AttachmentFan.tsx`:
- Around line 162-168: Use one live game-store snapshot for activation
decisions: in AttachmentFan.tsx at lines 162-168, derive affordances from the
same getState() result used for legalActionsByObject, re-check the entry gate
against that snapshot, and pass its manaTappableObjectIds value to
resolveObjectActivation; in PermanentCard.tsx at lines 689-702, replace the
context-derived canTapForMana with the value from the same getState() snapshot
supplying the action bucket so the gate and partition agree.

In `@client/src/viewmodel/__tests__/cardActionChoice.test.ts`:
- Around line 561-572: Add a test case similar to the existing "offers nothing
at Priority when the viewer cannot act for the waiting state" test, but for a
payment state such as ManaPayment or UnlessPayment. Call
deriveActivationAffordances with a payment state constant instead of PRIORITY,
set canActForWaitingState to false, and verify that both activatableObjectIds
and manaTappableObjectIds are empty. This ensures the seat-gate is enforced in
the mana branch and prevents opponent lands from being tappable when the viewer
cannot act during payment states.

In `@client/src/viewmodel/cardActionChoice.ts`:
- Around line 160-199: Refactor the derivation around playerCanAct and the
activatableObjectIds/manaTappableObjectIds updates to evaluate
canActForWaitingState once and iterate legalActionsByObject only once. Use a
typed set of waiting-state types to determine whether the current state enables
actions, then classify each object’s actions into the existing activatable and
mana sets while preserving the Priority-only restriction for activatable
indicators.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f88167c3-affe-444a-8b81-76e0a1aca541

📥 Commits

Reviewing files that changed from the base of the PR and between a970e55 and 38c0dc1.

📒 Files selected for processing (15)
  • client/src/components/board/AttachmentFan.tsx
  • client/src/components/board/GameBoard.tsx
  • client/src/components/board/PermanentCard.tsx
  • client/src/components/board/__tests__/AttachmentFan.test.tsx
  • client/src/components/board/__tests__/BattlefieldZoneOverflow.test.tsx
  • client/src/components/board/__tests__/GameBoardInteractionWiring.test.tsx
  • client/src/components/board/__tests__/PermanentCard.test.tsx
  • client/src/components/board/__tests__/abilityChoiceConsumerWiring.test.tsx
  • client/src/components/hud/DialogAttachmentCard.tsx
  • client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx
  • client/src/components/zone/CommandZone.tsx
  • client/src/components/zone/LibraryPile.tsx
  • client/src/components/zone/__tests__/CommandZone.test.tsx
  • client/src/viewmodel/__tests__/cardActionChoice.test.ts
  • client/src/viewmodel/cardActionChoice.ts

arcOffset={fan.arc(i)}
zIndex={i}
selectable={interactionFan !== null && id !== host.id}
selectable={id !== host.id && (interactionFan !== null || canActivate(id))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

selectable ignores canSubmit, so an unsubmittable interaction still paints a pick affordance.

During an interaction, selectable is true for every non-host card. handlePick then requires viewerInteraction?.canSubmit and returns without an effect when it is false. The card renders the cyan ring, the cursor-pointer class, and the permanent.fanPick badge, and the click does nothing.

Include the submit capability in the affordance so the visual and the action agree.

🐛 Proposed fix
-            selectable={id !== host.id && (interactionFan !== null || canActivate(id))}
+            selectable={
+              id !== host.id
+              && (interactionFan !== null
+                ? viewerInteraction?.canSubmit === true
+                : canActivate(id))
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
selectable={id !== host.id && (interactionFan !== null || canActivate(id))}
selectable={
id !== host.id
&& (interactionFan !== null
? viewerInteraction?.canSubmit === true
: canActivate(id))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/board/AttachmentFan.tsx` at line 222, Update the
selectable condition in AttachmentFan so interaction cards are selectable only
when the current interaction can submit, alongside the existing non-host and
activation checks. Keep the host exclusion and non-interaction canActivate
behavior unchanged, ensuring visual pick affordances match handlePick’s
canSubmit guard.

Comment thread client/src/components/hud/DialogAttachmentCard.tsx Outdated
Comment thread client/src/components/zone/CommandZone.tsx Outdated

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MED] Grouped emblem actions can make legal nonrepresentative emblems unreachable. Evidence: client/src/components/game/CommandZone.tsx:28-33 retains only a representative per grouped emblem; :78-88 increments the count without retaining member identities; and :146-161 derives the affordance and dispatches solely from representative.id. legalActionsByObject is keyed by exact object ID, so an action available only for a nonrepresentative member cannot be reached. Why it matters: the command-zone UI can hide a legal action for an emblem that shares a visible group. Suggested fix: preserve the group's member identities, choose a member with an active shared affordance, and resolve/pending-dispatch using that exact ID; add a two-emblem regression where only the nonrepresentative member is live.

…, and an exhaustive verdict

Review round on phase-rs#6992. Five fixes, all frontend, all routed through the same
two authorities the PR introduced — no new decision site.

1. Exhaustive `switch` + `never` at all FOUR `resolveObjectActivation`
   consumers (AttachmentFan, PermanentCard, DialogAttachmentCard, CommandZone).
   Only AttachmentFan's bare `else` errored on a new union variant; the other
   three compiled clean and silently dropped it. Behaviour is unchanged —
   `kind: "none"` still does nothing (it is reachable only through the
   render->click staleness window), and the fan's `close()` stays inside the
   two acting arms. CLAUDE.md: exhaustive match, no fallback default.

2. `resolveObjectActivation` took only the mana bit of a two-bit gate, so the
   non-mana partition was merged unconditionally and a cost-payment prompt
   offered activations the board itself refuses. It now takes the whole
   `ActivationAffordances` plus the object id and drops the non-mana partition
   when the activation ring is closed (CR 113.3b), mirroring the existing mana
   drop. Taking the pair rather than two loose booleans makes "half the gate"
   unrepresentable at a call site.

3. Sibling sweep: `CommanderCardZone` still gated `canCast`/`canNinjutsu` on
   the raw bucket with neither a `WaitingFor` nor a seat gate — the exact
   predicate this PR replaced at `CommandZone.EmblemCard`, in a component
   `PlayerArea` renders for every seat. Pre-existing, not introduced.

4. Grouped-emblem activation identity (maintainer review, [MED]; CodeRabbit
   comment 3). `GroupedEmblem` kept a representative plus a tally, so an action
   the engine published against a non-representative member was unreachable.
   It now retains every member; the chip acts on the member the shared
   affordance sets name, and the count badge is that list's length. This
   changes which id the authority is asked about, not who decides.

5. A `PermanentCard` fixture seeded a mana-only affordance pair while omitting
   `is_mana_ability` on the abilities — a state the engine cannot emit, since
   the deriver and the resolver classify through the same `isManaObjectAction`.
   Fix 2 surfaced it; the flags make the row exercise the mana partition.

Two-sided controls, each flipping its own named assertion: the union-variant
arms (1 error unfixed / 4 errors fixed / 0 with the real union); `if (true)`
and `if (false)` on the activation ring; raw-bucket-restore and constant-false
on the commander flags; representative-only and always-last-member on the
emblem group.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@matthewevans — your [MED] is fixed at 5ea5dc100, together with the other four items from this round. Thank you for catching it; our own triage had mis-filed CodeRabbit's identical finding as a duplicate of its exhaustive-switch comment, so this would have shipped without you.

Path note first, so the evidence trail matches: you cite client/src/components/game/CommandZone.tsx. That file does not exist — find client/src -name "CommandZone*.tsx" returns only client/src/components/zone/CommandZone.tsx (plus its test). Your line numbers and substance match the real file exactly, so we read it as components/zone/ and fixed it there.

Confirmed structurally before fixing, not accepted on authority:

  • interface GroupedEmblem { description; sourceName; count; representative: GameObject } — no member identities retained.
  • Grouping loop: if (existing) { existing.count++ } — the member object was discarded.
  • EmblemCard: isActivatable read affordances.*.has(emblem.id) and handleActivate resolved and dispatched on emblem.id — representative-only on both the gate axis and the dispatch axis, exactly as you described.

The fix is the one you suggested. GroupedEmblem now carries members: GameObject[] instead of count + representative. EmblemCard selects the member the shared affordance sets actually name and resolves / dispatches / pends on that exact id; members[0] is display identity only (art, source and rules text are group-invariant by construction of the source | description key); the count badge is members.length, so it can no longer drift from the ids the chip can act on. find takes the first offered member, so one chip is still one dispatch. It stays routed through the existing deriveActivationAffordances / resolveObjectActivation authority — this changes which id is asked about, not who decides, so there is no second decision site, and it is class-general for any group size and any member position rather than a two-emblem special case. CR 114.1 (an emblem is a marker representing its own object) + CR 114.4 (its abilities function in the command zone).

The regression you asked for, plus its controlsclient/src/components/zone/__tests__/CommandZone.test.tsx, new describe("CommandZone grouped-emblem activation identity"). Every row first asserts the structural precondition (getAllByTestId("emblem-card") has length 1 and the ×2 badge renders), so no row can pass because the sibling rendered its own chip:

  • only the non-representative member is live → the chip is offered and dispatches the sibling's action under the sibling's id;
  • the chooser carries the member's own id (pendingAbilityChoice.objectId === 701), since dispatch was not the only axis you named;
  • control: only the representative is live → still works;
  • control: every member is live → exactly one dispatch (the first in command-zone order) and the count is still ×2;
  • negative control: no member is live → inert, no role="button", no dispatch.

Two-sided, measured, both arms transcribed:

  • DROP (restore representative-only derivation on both axes, i.e. the pre-fix code): 2 rows failAssertionError: expected false to be true // Object.is equality on the chip's own data-activatable, and AssertionError: expected null to deeply equal { objectId: 701, actions: [ …(2) ] } on the chooser id.
  • TRIVIALIZE ("always the last member" instead of the affordance-driven find): 5 rows fail — both controls, the negative control, and the pre-existing timing and seat rows in the describe above.

Full suite at this head: npx tsc -b --noEmit --force EXIT=0; npx vitest run EXIT=0, Test Files 285 passed | 3 skipped (288), Tests 2518 passed | 12 todo (2530); npx eslint . warning set unchanged versus the previous head (onlyInBASE=0 onlyInPOST=0, react-hooks/exhaustive-deps 2 on both arms, with a positive control proving the comparison can detect a change).

The PR body now also carries the other four fixes from this round, a corrected test-count attribution (+53 tests versus the merge base, measured with two independent instruments — the previous body's "42" was wrong), and two explicitly named follow-ups we did not do: five components each memoizing the same pure affordance call rather than sharing a hook (LOW, no correctness content), and the engine-side reachability of the CR 113.3b gate, which is UNMEASURED because it needs a cargo run this checkout cannot give us right now.

The PR stays in draft until CI is green.

@lgray
lgray marked this pull request as ready for review August 4, 2026 13:51
@lgray

lgray commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@matthewevans — status update following your review. All five fixes are pushed at 5ea5dc100; the PR is out of draft and CI is terminal green.

Your finding is addressed. GroupedEmblem now retains members: GameObject[] instead of a single representative + count; the chip selects the member the shared affordance set names and resolves, dispatches, and pends on that exact object id. members[0] is display identity only, and the badge reads members.length. This changes which id is asked about, not who decides — activation still routes through the same authority, so no second decision site is introduced, and it is general for any group size or member position.

Regression added exactly as you specified — two grouped emblems where only the non-representative member is live:

  • DROP control (representative-only on both axes, i.e. the pre-fix behaviour): 2 rows fail — expected false to be true on data-activatable, and expected null to deeply equal { objectId: 701, actions: [ …(2) ] } on the chooser id.
  • TRIVIALIZE control ("always the last member"): 5 rows fail, including both positive controls and the negative control.
  • Every new row first asserts the structural precondition (exactly one emblem-card, ×2 badge), so none can pass merely because the sibling rendered its own chip.

One note on the reference: the path in your review reads client/src/components/game/CommandZone.tsx, which does not exist in the tree — we read it as client/src/components/zone/CommandZone.tsx, where your line numbers and evidence match exactly.

CI at this head: Frontend (lint, type-check, test), Rust lint, both Rust test shards, Card data, WASM, Tauri, and Lobby worker all pass. Suite delta is +5 files / +53 tests, attributed by two independent instruments.

I don't have permission to request a re-review through the API, so flagging it here instead — this is ready for another look whenever you have time.

@matthewevans matthewevans self-assigned this Aug 4, 2026
@matthewevans matthewevans added the bug Bug fix label Aug 4, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold: current-head external review is still running.

The grouped-emblem changes-requested finding was addressed on current head 5ea5dc100cb04e7eff820f8026e8ef5d2fba8f66, but CodeRabbit is still pending for that exact head. I will reconcile its current-head feedback before issuing an approval or enabling the merge queue. No contributor action is requested while that review is pending.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — current-head grouped-emblem activation preserves exact member identity and the command-zone regression covers a live nonrepresentative member.

@matthewevans
matthewevans added this pull request to the merge queue Aug 4, 2026
@matthewevans matthewevans removed their assignment Aug 4, 2026
Merged via the queue into phase-rs:main with commit 5fbfeac Aug 4, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants