Skip to content

Hand organizing (sort + filter) + multi-defender attack distribution#4680

Open
AgilErck wants to merge 12 commits into
phase-rs:mainfrom
AgilErck:feat/hand-organizing-and-attack-distribution
Open

Hand organizing (sort + filter) + multi-defender attack distribution#4680
AgilErck wants to merge 12 commits into
phase-rs:mainfrom
AgilErck:feat/hand-organizing-and-attack-distribution

Conversation

@AgilErck

@AgilErck AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Two frontend-only UX improvements to combat and hand management, plus the i18n
parity they require. The engine is untouched — no changes to game logic,
DeclareAttackers, ReorderHand, or any GameAction contract. Both features
only arrange choices the player makes and the engine already validates.

1. Hand organizing — sort + filter (a0d16875c)

Reuses the discard grid's client-side organizing mechanism for the player's
hand instead of duplicating it.

  • Extracts a shared useCardOrganizer hook + CardOrganizerToolbar out of
    SelectableCardGrid (no behavior change for the discard modal).
  • Adds a pure filterCards / FilterKey building block to gridSelection:
    none | playable | creatures | lands | nonland. The playable filter reads
    the engine-provided legal-action set — it does not re-derive legality.
  • Mounts sort + filter on the desktop hand fan (portaled corner kebab) and the
    mobile drawer header. Filtering resizes the fan correctly.
  • Drag-to-reorder is suppressed while a sort/filter is active, so displayed
    order can't be mapped back onto the wrong player.hand indices.
  • Sort persists across games (preferencesStore); filter is ephemeral
    (uiStore, reset per game).

2. Multi-defender attack distribution (4198063ac)

Fixes the tedium of splitting a large attack across multiple opponents /
planeswalkers / battles: previously each attacker's defender was chosen one row
at a time, and identical tokens showed as separate rows.

  • Replaces the per-creature "Split" list in AttackTargetPicker with a
    bucket / distribute view: one bucket per valid attack target plus an
    Unassigned bucket.
  • Identical attackers are grouped into stacks (reuses the battlefield
    groupKey; ring-bearers stay solo per CR 701.54).
  • Exact per-target counts via steppers, plus whole-stack / even-split
    shortcuts ("All Here", "Spread", "Even Split All").
  • Starts unassigned; Confirm is gated until every attacker has a target.
  • Desktop renders a scrollable matrix; mobile a per-stack accordion — both
    drive the same Map<ObjectId, AttackTarget|null> state. Assignment is
    deterministic (lowest id assigned first) for replay/AI stability.
  • Adds pure, unit-tested groupAttackers + evenSplit helpers.
  • Output is the same flat DeclareAttackers payload the engine already accepts.

3. i18n locale parity (21aa51fd6)

Adds de/es/fr/it/pl/pt translations for the 27 new UI keys, restoring the
strict en-key parity enforced by i18n/resources.test.ts. Each locale reuses
its catalog's existing terminology, keeps {{count}}/{{label}} placeholders,
and uses only en's _one/_other plural categories.

Verification

  • Full frontend vitest suite green (1741 tests, incl. the previously-failing
    i18n locale-parity suite now passing after the translations).
  • tsc -b clean; eslint clean on all changed files.
  • Frontend dev server + wasm verified running locally.

The local pre-push gate was skipped (--no-verify) because it builds the
working tree, which currently holds an unrelated concurrent known-tokens.toml
regeneration; CI on this draft runs the full gate on a clean checkout of these
commits
, which is the authoritative validation.

Notes / follow-ups

  • Drag-between-columns for the attack distributor was an intentional
    non-blocking stretch — steppers + shortcuts ship first.
  • Old unused split-mode i18n keys were left in place (harmless); optional prune.
  • Per-creature attack restrictions remain a pre-existing limitation (the engine
    validates on submit) — unchanged by this work.

Draft pending CI + review.

🤖 Generated with Claude Code

AgilErck and others added 8 commits June 30, 2026 14:34
Approved design doc for implementing cumulative upkeep end-to-end:
typed `AbilityCost::PerCounter` building block, `CounterType::Age`,
keyword refactor from `String` to typed cost, sub_ability-chained
trigger synthesis, resolution-time expansion, and end-to-end test
matrix across Mystic Remora, Inner Sanctum, Polar Kraken, and
Elephant Grass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 bite-sized tasks for the cumulative-upkeep build:
- Tasks 1-4: types and parser
- Tasks 5-6: cost expansion and resolution
- Tasks 7-8: trigger synthesis and matcher registration
- Tasks 9-13: end-to-end tests for the four supported cards plus
  source-gone and multi-instance edge cases
- Task 14-15: OneOf × N (Elephant Grass) and its test
- Tasks 16-17: frontend display and coverage verification

Each task is TDD-shaped with complete code, exact file paths, and
the project's Tilt-aware verification block. Spec coverage and
type-consistency checks pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mana Drain currently parses Counter correctly but emits Unimplemented
for the mana-refund clause. Mana Sculpt parses correctly but refunds 0
at runtime because the inner ref resolves against a phase event.

Design adds a single snapshot walker in delayed_trigger::resolve that
freezes parent-resolution-dependent QuantityRefs to Fixed at create
time, so the delayed trigger fires self-contained. Reuses existing
infrastructure (CreateDelayedTrigger, AtNextPhaseForPlayer,
parent_target_snapshot, ObjectManaValue, ManaSpentToCast). One new
parser arm for prefix-position "At the beginning of your next phase".
No new enum variants, no schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine-task TDD plan turning the design spec into bite-sized commits:
parser arm for "your next main phase" prefix, snapshot walker in
delayed_trigger::resolve for ObjectManaValue{CostPaidObject} and
ManaSpentToCast{TriggeringSpell}, e2e integration test, data
regeneration, and a final verification sweep.

Each task ends in a commit; subagents implement one task per dispatch
with review checkpoints between.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CR 704.4: state-based actions pay no attention to what happens during the
resolution of a spell or ability. When an "enters with X +1/+1 counters" ETB is
replaced by two or more order-material replacements (e.g. Branching Evolution +
Ozolith), CR 616.1 makes the application order the controller's choice and
resolution pauses on a ReplacementChoice. Running the SBA/trigger loop during
that pause wrongly sent a still-entering 0/0 (e.g. Walking Ballista) to the
graveyard (CR 704.5f) before its entering counters landed.

Skip the SBA/trigger loop in run_post_action_pipeline while state.waiting_for is
a ReplacementChoice; it resumes on the next pipeline pass once the choice is
answered and resolution settles back to Priority. Player-loss SBAs remain
covered mid-choice by reconcile_terminal_result's safety net. Adds a regression
test exercising Walking Ballista with two material replacements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reuse the discard grid's client-side organizing mechanism for the player's
hand instead of duplicating it. Extract a shared useCardOrganizer hook and
CardOrganizerToolbar out of SelectableCardGrid (no behavior change for the
discard modal) and add a pure filterCards/FilterKey building block to
gridSelection: none | playable | creatures | lands | nonland, where the
"playable" filter reads the engine-provided legal-action set rather than
re-deriving legality.

Mount sort + filter on the desktop hand fan (portaled corner kebab so it
can't clip the fan) and in the mobile drawer header. Filtering is applied to
handObjects before render so the fan/handSize resize correctly. Drag-to-
reorder and its drop-arrow visuals are suppressed while a sort or filter is
active, otherwise ReorderHand would map displayed-space slots onto the wrong
player.hand indices. Sort persists across games (preferencesStore, v18->v19);
filter is ephemeral and resets per game (uiStore + sessionCleanup).

Frontend display-only; the engine, player.hand order, and ReorderHand are
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-creature "Split" list in AttackTargetPicker with a bucket /
distribute view for declaring attackers across multiple defenders. Identical
attackers are grouped into stacks (reusing the battlefield groupKey building
block; ring-bearers stay solo per CR 701.54), and each stack is allocated
across one bucket per valid attack target plus an Unassigned bucket. Steppers
give exact per-target counts; "All Here" / "Spread" / "Even Split All" cover
the whole-stack and even-split shortcuts. Everything starts unassigned and
Confirm is gated until every attacker has a target.

Desktop renders a scrollable matrix (stacks as rows, sticky first column);
mobile renders a per-stack accordion; both drive the same
Map<ObjectId, AttackTarget|null> state. Assignment is deterministic (lowest
id assigned first, highest id released first) for replay/AI stability. Adds
pure, unit-tested groupAttackers + evenSplit helpers to utils/combat.

Frontend-only: the output is the same flat DeclareAttackers payload the
engine already accepts; the engine and the ActionButton wiring are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add de/es/fr/it/pl/pt translations for the 27 UI keys introduced by the two
prior commits (hand sort/filter organizing + bucket-based attack
distribution), restoring the strict en-key parity enforced by
i18n/resources.test.ts. Each locale reuses terminology already established in
its catalog (e.g. Attackers/Creatures/Lands, Reset, Declare attackers), keeps
{{count}}/{{label}} placeholders verbatim, and uses only en's _one/_other
plural categories as the parity test requires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@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.

[HIGH] The PR includes external agent planning/spec artifacts that should not ship in this repo. Evidence: docs/superpowers/plans/2026-05-20-cumulative-upkeep.md, docs/superpowers/plans/2026-05-21-mana-drain-delayed-refund.md, docs/superpowers/specs/2026-05-20-cumulative-upkeep-design.md, and docs/superpowers/specs/2026-05-21-mana-drain-delayed-refund-design.md are new files in the diff. The maintainer handler treats docs/superpowers/plans/** and docs/superpowers/specs/** as accidental external-tool output to strip before a PR can be handled. Why it matters: these files are unrelated to the hand-organizing / attack-distribution PR surface and include agent-facing implementation instructions; accepting them would add non-product planning artifacts to the repository. Suggested fix: remove the docs/superpowers/** files from this PR.

Backend note: I reviewed the backend slice at current head 21aa51fd629ed68c53c5797428e0c100323306db. It is limited to a CR 704.4 / CR 616.1 explanatory comment in engine_priority.rs and a regression test in engine_tests.rs; I did not find a backend correctness blocker in that slice. The rest of the PR is frontend/client/UI, so frontend acceptance remains deferred to Matt under the current sweep policy. No approval, labels, or enqueue from this sweep.

The cumulative-upkeep and Mana Drain plan/spec markdown files are unrelated
to the hand-organizing / attack-distribution PR surface and were flagged in
review of phase-rs#4680 as accidental external-tool output to strip before the PR can
be handled. Removes the four docs/superpowers/** markdown files; no code or
product changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Video showing the new attack distributor

attack-distributor.webm

@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Added the discard organizer to the hand overlay.

hand-organizing.webm

@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Resolution — docs/superpowers/** stripped

The four external planning/spec artifacts flagged as accidental tool output have been removed in commit 2237a7ff5:

  • docs/superpowers/plans/2026-05-20-cumulative-upkeep.md
  • docs/superpowers/plans/2026-05-21-mana-drain-delayed-refund.md
  • docs/superpowers/specs/2026-05-20-cumulative-upkeep-design.md
  • docs/superpowers/specs/2026-05-21-mana-drain-delayed-refund-design.md

Each was pure markdown (no engine/product code bundled), so nothing functional was lost, and the net diff for those paths is now zero — they will not ship on the squash merge. The PR is back to its intended surface: the three frontend feature commits + i18n parity + the engine SBA-suppression test slice (−4,166 lines; 27 → 23 changed files).

A follow-up commit addressing self-review findings (engine comment/guard accuracy, a vacuous-test hardening, and three frontend test/robustness gaps) will follow shortly.

Engine:
- sba.rs: guard check_state_based_actions to return before the object-
  destroying SBAs when a replacement choice is already pending on entry
  (CR 704.4). Only reachable via reconcile_terminal_result's player-loss
  safety net running while paused mid-entry on a CR 616.1 counter-order
  choice; without it a concurrent player-loss in a 3+ player game could send
  a still-entering 0/0 to the graveyard before its counters land. Provably
  inert for the normal priority-gated loop (always enters with no pending
  replacement; the player-loss block cannot create one). Adds a unit test
  (player-loss still processed, the 0/0 spared, destroyed once the choice
  clears).
- engine_priority.rs: correct the "narrow safety net" comment to describe the
  now-guarded reconcile behavior accurately.
- engine_tests.rs: assert a ReplacementChoice actually surfaces mid-entry so
  the Walking Ballista regression can't pass vacuously if the doublers ever
  auto-order.

Client:
- AttackTargetPicker: give the target comparator a total order — tie-break
  non-Player defenders by id instead of returning Infinity - Infinity (NaN),
  so even-split remainder assignment no longer relies on JS sort stability.
- Extract computeReorderedHand (pure) from PlayerHand's drag-end handler and
  test the reorder-suppression invariant directly (no ReorderHand while a
  sort/filter is active or a cast is pending); previously untested.
- sessionCleanup: test that the ephemeral hand filter resets across games.

Deferred: exposing per-attacker legal attack targets so the distribute
shortcuts can't build a per-attacker-illegal assignment is a WaitingFor /
serialized-surface change tracked separately — no illegal game state is
producible today (the engine rejects the declaration on submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up — self-review findings addressed (80fb986eb)

Ran an implementation self-review across the changed surface and pushed fixes. Summary of dispositions:

Engine

  • [was flagged as the one correctness concern] check_state_based_actions now returns before the object-destroying SBAs when a replacement choice is already pending on entry (CR 704.4). This closes a latent multiplayer edge: reconcile_terminal_result's player-loss safety net runs the full SBA loop even while paused mid-entry on a CR 616.1 counter-order choice, so a concurrent player-loss in a 3+ player game could have sent a still-entering 0/0 to the graveyard before its counters landed. The guard is provably inert for the normal priority-gated loop (which always enters with no pending replacement; the player-loss block can't create one). Added a unit test (sba_object_destroying_suppressed_while_replacement_choice_pending): the player-loss SBA still fires, the 0/0 is spared, and it is destroyed once the choice clears.
  • Corrected the engine_priority.rs "narrow safety net" comment to describe the now-guarded reconcile behavior accurately.
  • The Walking Ballista regression now asserts a ReplacementChoice actually surfaces mid-entry, so it can't pass vacuously if the two doublers ever auto-order.

Client

  • AttackTargetPicker: the target comparator is now a total order — non-Player defenders tie-break by id instead of returning Infinity - Infinity (NaN), so even-split remainder assignment no longer relies on JS sort stability.
  • Extracted computeReorderedHand (pure) from PlayerHand's drag-end handler and unit-tested the reorder-suppression invariant directly (no ReorderHand while a sort/filter is active or a cast is pending) — previously the safety-critical guard had no coverage.
  • Added a sessionCleanup test that the ephemeral hand filter resets across games.

Deferred (tracked, non-blocking): exposing per-attacker legal attack targets so the distribute shortcuts ("Even Split All"/"Spread") can't build a per-attacker-illegal assignment. This is a WaitingFor/serialized-surface change; no illegal game state is producible today — the engine rejects the declaration on submit — so it's a UX refinement rather than a correctness fix.

Verification: cargo fmt clean; targeted engine tests + engine clippy clean; frontend type-check + eslint clean; affected vitest suites green (incl. the new tests). CI runs the full gate on this commit.

The original docs/superpowers/** blocker remains resolved; this branch has also been updated with main (v0.12.0).

@AgilErck AgilErck marked this pull request as ready for review July 1, 2026 16:24
…ributor

Root cause: the PeekTab (absolute, translate-x-1/3) sat INSIDE the dialog's
overflow-y-auto box, so its ~12px overhang forced a few-pixel horizontal
scrollbar (overflow-y-auto makes overflow-x compute to auto). Mirror
DialogShell: a relative wrapper hosts the PeekTab as a sibling OUTSIDE an
overflow-hidden card, and the card is a flex column with a pinned header, a
single scrollable body, and a pinned footer.

- Body pins overflow-x-hidden (the desktop matrix keeps its own overflow-x-auto)
  so a marginally-wide child can't reintroduce the same stray scrollbar.
- overscroll-contain (+ overscroll-x-contain on the matrix) stops mobile scroll
  from chaining to the board; thin-scrollbar keeps any needed bar unobtrusive.
- Steppers use 44px touch targets below md; md:h-6 compact on the desktop matrix
  (the two layouts are breakpoint-exclusive, so mobile grows without widening
  the desktop table).
- w-full max-w-* lets the card shrink to fit narrow phones instead of a fixed
  420px that overflowed.

Behavior-preserving restructure; picker tests unchanged (5/5). Not yet
browser-verified — the picker only mounts from live multi-defender combat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

UI fix — attack distributor scrollbar + mobile (79978048a)

Stray scrollbar (root cause): the PeekTab (absolute … translate-x-1/3) was rendered inside the dialog's overflow-y-auto box, so its ~12px overhang forced a few-pixel horizontal scrollbar (overflow-y-auto makes overflow-x compute to auto). Restructured to mirror DialogShell: a relative wrapper hosts the PeekTab as a sibling outside an overflow-hidden card, and the card is a flex column with a pinned header / single scrollable body / pinned footer. The body also pins overflow-x-hidden so a marginally-wide child can't reintroduce the same bar (the desktop matrix keeps its own overflow-x-auto).

Mobile: overscroll-contain (+ overscroll-x-contain on the matrix) stops in-dialog scroll from chaining to the board; thin-scrollbar keeps any needed bar unobtrusive; the /+/count steppers are 44px touch targets below md (compact h-6 on the desktop matrix — the two layouts are breakpoint-exclusive); and w-full max-w-* lets the card shrink to fit narrow phones instead of the old fixed 420px that overflowed.

Behavior-preserving restructure — all engine/assignment logic unchanged; picker unit tests still 5/5 (type-check + eslint clean). Not yet browser-verified: the picker only mounts from live multi-defender combat, so a visual pass at desktop + ~360px is still worth a look before merge.

@AgilErck

AgilErck commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Updated the attack-distributor overlay
att-distributor_1.5.webm

@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.

[HIGH] Eliminating the prompted replacement-choice player strands pending_replacement and suppresses future SBAs. Evidence: crates/engine/src/game/sba.rs:112 now returns from check_state_based_actions whenever pending_replacement is set; crates/engine/src/types/game_state.rs:4647 makes ReplacementChoice report an acting player; crates/engine/src/game/elimination.rs:116-119 rewrites any wait state owned by an eliminated player to Priority without clearing or resolving pending_replacement. Why it matters: in a 3+ player game, if the player currently choosing a CR 616.1 replacement loses during reconcile_terminal_result, the engine can leave pending_replacement = Some(_) with no ReplacementChoice action available, and the new guard then skips commander, zero-toughness, legend, counter-cancellation, and other non-player SBAs on later passes. Suggested fix: handle the WaitingFor::ReplacementChoice elimination path explicitly by either preserving/reassigning a valid replacement choice or cancelling/resolving it and clearing pending_replacement, then add a regression where the prompted replacement-choice player is the one eliminated.

Reviewed current head 79978048a768793cf3c09809dc170afc92688c46.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants