Skip to content

feat: auto-pass control suite — priority yields, scoped phase stops, turn boundaries#5132

Merged
matthewevans merged 9 commits into
mainfrom
ship/auto-pass-control-suite
Jul 5, 2026
Merged

feat: auto-pass control suite — priority yields, scoped phase stops, turn boundaries#5132
matthewevans merged 9 commits into
mainfrom
ship/auto-pass-control-suite

Conversation

@matthewevans

Copy link
Copy Markdown
Member

Five commits landing the auto-pass control improvements:

  • fix(engine): narrow opponent-turn mana auto-pass guard to feasibly-castable spells (CR 117.1d) — follow-up to fix(engine): hold priority for mana on opponent's turn (CR 117.1d, #4388) #4814/Itlimoc, Cradle of the Sun and Gaea's Cradle. — Having trouble activating these during opponents turns. #4388: a lone untapped Forest with nothing castable no longer holds priority on opponents' turns.
  • feat(engine): MTGO-style per-trigger priority yields (CR 117.3d) — long-press a stack trigger to auto-yield to this occurrence or all copies; identity latched at trigger push (CR 400.7-safe for ceased tokens); per-viewer redaction; UI list to revoke.
  • feat: turn-scoped phase stops (CR 102.1) — each phase stop carries AllTurns/OwnTurn/OpponentsTurns scope; engine is single authority for the empty-stack gate (moved out of the FE); legacy bare-Phase stops deserialize via untagged serde compat; preferences v22→v23 migration; PhaseStopBar cycles scopes.
  • refactor: parameterize UntilEndOfTurn into UntilTurnBoundary (CR 500.1) — fixes the docstring/behavior mismatch and makes MyNextTurnStart (multiplayer pass-until-my-turn) engine-reachable; legacy payloads deserialize via serde alias.
  • feat(client): scale auto-pass beat by animation speed preference — instant animation speed collapses the 200ms beat to 0 (pass still dispatches).

All engine work ran the plan-review/impl-review pipeline to clean verdicts; serde compat covers persisted IndexedDB/SQLite states and old action payloads.

…stable spells (CR 117.1d)

The #4814 guard held priority on an opponent's turn whenever ANY object-level
mana action was activatable — a bare Forest with an empty hand blocked
auto-pass at every priority window, making smart-pass inert on opponents'
turns. Hold only when the mana could enable an action: the opponent-turn rung
now delegates to has_feasibly_castable_spell (covers auto-tap AND manual-float
payment), placed above the upkeep/draw fast path; the own-turn castability rung
is active_player-gated so the predicate runs at most once per call and never on
your own upkeep/draw. Meaningful non-mana abilities and sacrifice-for-mana
(#544) still hold via the untouched has_meaningful_priority_action rung; a
has_feasibly_activatable_ability opponent-turn seam is documented as deferred.

Follow-up to #4814 / #4388.
Players can pre-decline priority for a specific trigger source (yield
this trigger, CR 400.7 incarnation-guarded) or every trigger sharing a
card identity (yield all copies), MTGO-style. Yields short-circuit
auto_pass_recommended ahead of the castability rungs and keep
until-end-of-turn auto-pass sessions running through yielded opponent
triggers.

Identity is latched at trigger push on ResolvedAbility (existing
source_incarnation plus new source_card_id) and re-latched at Add time
from the on-stack entry, never live objects, so ceased tokens (CR
704.5d) stay yieldable. SetPriorityYield is an actor-routed preference
action: legal in any state, exempt from auto-pass session clearing,
redacted per viewer, persisted across turns.

FE adds a long-press yield menu on triggered stack entries, an active
yield list with revoke/clear-all, and i18n across all 7 locales.
Phase stops gain a turn-direction scope: PhaseStop { phase, scope } with
PhaseStopScope { AllTurns, OwnTurn, OpponentsTurns }, resolved against the
active player at every engine auto-pass site via GameState::phase_stop_hit.
Legacy bare-Phase stops (persisted saves, old action payloads) deserialize
as AllTurns through an untagged serde compat type. The FE empty-stack
auto-pass gate moves into engine auto_pass_recommended; session.rs
SetPhaseStops now delegates to apply(). Client: preferences v22→v23
migration, PhaseStopBar scope cycling, i18n scope labels across 7 locales.
AutoPassMode/AutoPassRequest UntilEndOfTurn becomes
UntilTurnBoundary { until: TurnBoundary } with
TurnBoundary { EndOfCurrentTurn (default), MyNextTurnStart }. Fixes the
docstring/behavior mismatch (the stored-mode doc claimed pass-until-my-
next-turn while the clear at turn start fired on every turn boundary):
docs now state EndOfCurrentTurn semantics, and MyNextTurnStart delivers
the documented behavior as an engine-reachable option — it survives
opponents' turn starts and clears only when the owner's own turn begins
(CR 102.1), which differs in multiplayer by N-1 turns. Legacy persisted
{"type":"UntilEndOfTurn"} payloads deserialize to EndOfCurrentTurn via
serde alias + defaulted field under internal tagging. UI still sends
EndOfCurrentTurn only.
The artificial auto-pass wait now composes the user's Animation Speed
multiplier with the existing stack-pressure scaling, matching the
duration-scaling convention of banner/dice pacing. At 0 (instant) the
beat collapses to 0ms but the pass still dispatches — only the wait is
skipped, never the action.
@matthewevans matthewevans enabled auto-merge July 5, 2026 08:24

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements MTG Comprehensive Rule 117.3d, introducing standing priority-yield preferences (allowing players to pre-commit to passing priority on specific triggered abilities) and turn-direction scopes for phase stops (AllTurns, OwnTurn, and OpponentsTurns). Key changes include migrating phase-stop gating from the frontend to the engine, updating auto-pass to support turn boundaries, adding a priority-yield context menu to stack entries, and rendering a list of active yields. Feedback on these changes highlights three main areas of improvement: falling back to the engine-stamped source_card_id in StackEntry.tsx to correctly resolve yields for ceased tokens, updating phase stops in-place in PhaseStopBar.tsx to preserve array order and prevent redundant sync dispatches, and increasing the touch target size of the priority-yield menu buttons to meet the style guide's mobile accessibility standards.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread client/src/components/stack/StackEntry.tsx
Comment thread client/src/components/controls/PhaseStopBar.tsx
Comment thread client/src/components/stack/StackEntry.tsx
matthewevans and others added 3 commits July 5, 2026 07:19
- Match AllCopies priority yields via the engine-stamped source_card_id so a
  ceased token's trigger can still surface Revoke (CR 400.7 / CR 704.5d); the
  TS ResolvedAbility mirror was missing the field, forcing the display layer to
  re-derive identity from the now-absent source object.
- Cycle phase stops in place (map) instead of appending, preserving array order
  so usePhaseStopsSync's positional dedup doesn't fire a redundant dispatch.
- Give the yield-menu buttons min-h-[44px] touch targets (mobile a11y).

Adds discriminating tests: ceased-token Revoke match and in-place cycle order.
The priority-yield commit added source_card_id to ResolvedAbility and updated
the exhaustive destructures in ability_scan.rs and stack.rs, but missed the one
in walk_ability (ability_rw.rs), so a clean build failed with E0027. It is a
latched card-identity token with no read/write effect on the RwProfile, so bind
it as `source_card_id: _` alongside the sibling source_incarnation latch.
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 2 card(s), 2 signature(s) (baseline: main f1cc915c8eed)

2 card(s) · static/Continuous · added: Continuous (affects=equipped by self permanent, mods=remove type planeswalker, add type creature)

Examples: Luxior and Shadowspear, Luxior, Giada's Gift

2 card(s) · ability/static_structure · removed: static_structure

Examples: Luxior and Shadowspear, Luxior, Giada's Gift

2 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

build_triggered_ability stamps the source's card identity (source_card_id) on
every triggered ability for AllCopies priority-yield matching. That field rides
in the derived ResolvedAbility ==, but normalize_ability_identity — which strips
per-instance identity before the CR 603.3b indistinguishability comparison —
only zeroed source_id, not source_card_id. Two outcome-identical triggers off
distinct sources (distinct CardId) then compared as distinguishable and lost
their auto-ordering, regressing order_triggers_identical_no_input_triggers_auto_order,
issue_860 (two Luminarch Aspirants), multiple_suspended_cards_all_tick_on_upkeep,
and issue_4269 (two Shamblers). Zero source_card_id alongside source_id: it is a
source-identity latch with no bearing on a trigger's game outcome.
@matthewevans matthewevans added this pull request to the merge queue Jul 5, 2026
Merged via the queue into main with commit a41c753 Jul 5, 2026
11 checks passed
@matthewevans matthewevans deleted the ship/auto-pass-control-suite branch July 5, 2026 15:31
github-merge-queue Bot pushed a commit that referenced this pull request Jul 6, 2026
* feat(client): discoverable tap affordance for priority yields (CR 117.3d)

The priority-yield auto-pass control shipped in #5132 was reachable only by
long-pressing a triggered stack entry — an undiscoverable gesture that also
collided with the mobile card-preview long-press (both fired on the same
triggered entry, so trigger cards could no longer be previewed).

Replace it with an always-visible tap button on triggered-ability entries
that toggles the existing yield menu, and restore long-press to a single
uniform meaning (inspect-and-pin) for every entry. The button sits on the
center-right edge — the one card zone clear of the status badges, chip row,
and ability overlay — and turns amber (bell-off icon) when a yield already
stands for the trigger. Pure display/dispatch: the engine still owns all
yield state via SetPriorityYield / priority_yields.

* fix(engine): auto-pass past bare sacrifice-for-mana with no downstream use (CR 117.1d)

auto_pass_recommended held frontend priority at every window whenever the player
controlled a sacrifice-for-mana permanent (Eldrazi Spawn, Krark-Clan Ironworks,
Phyrexian Altar), even when the mana could pay for nothing and the sacrifice
triggered nothing. On an opponent's turn with an uncastable hand the player was
asked for priority endlessly (repro: 8 Eldrazi Spawn, 3 sorceries in hand,
opponent trigger on the stack, legal_actions == [PassPriority]).

Narrow the issue-#544 sac-for-mana hold: it now blocks auto-pass only when the
sacrifice or its mana enables a concrete follow-up — a feasibly castable spell
(already handled by the #4388 castability rungs above), a mana-costed non-mana
activated ability (case 2), or a leaves-battlefield/dies/sacrifice trigger
(case 3, aristocrats/altars). A bare sac-for-mana with none of these auto-passes.

The narrowing lives entirely in auto_pass_recommended. has_meaningful_priority_action
is kept byte-identical (extracted into flat_actions_have_meaningful_priority +
has_activatable_sacrifice_for_mana, then recomposed) because the CR 732.5
loop-shortcut firewall depends on it still counting a sacrifice as a board change.
mana_sources.rs is untouched.

Cases 2 and 3 are conservative presence checks that err toward holding, so no real
play is ever silently auto-passed; a precise sac->activate affordability check is a
documented follow-up. 'Counter unless pay {1}' is unaffected — that payment is a
WaitingFor::UnlessPayment prompt at resolution, never a priority window (regression
test V7).

CR 605.3a + 603.6 + 603.6c + 117.1d + 118.12a.

* fix(stack): make yield affordance discoverable and dismiss preview on open

The always-visible yield control on triggered stack entries was a bare
dark icon that read as decoration and was easy to miss. Replace it with a
high-contrast labeled pill (indigo resting / amber when a yield stands)
carrying a compact 'Auto-pass' label so its purpose is legible without
hovering. Add menuButtonShort / menuButtonShortActive across all 7 locales.

Also clear the hovered/sticky card preview when the yield menu opens and
gate the hover handler while it's open, so the preview no longer lingers
on top of the menu while the player reads the options (mouseenter never
re-fires or clears as the pointer moves onto the menu's child elements).

* fix(client): make priority-yield menu options work and collapse standing yields into a compact chip (CR 117.3d)

- PopoverMenu: seal onPointerDown as well as onClick on the portal
  container so a menu interaction never leaks through the React tree to
  the card's useLongPress (which was firing the card preview and leaving
  the menu open — the "options do nothing" bug). Bubble-phase only, so the
  capture-phase outside-click dismissal is unaffected.
- PriorityYieldList: collapse the unbounded vertical list into one
  fixed-footprint amber "Auto-passing N" chip that opens the revocable
  list inside the now-sealed PopoverMenu, keeping the action rail height
  constant regardless of yield count.
- FullControlToggle: shorten the visible label to "Control" (aria-pressed
  + descriptive aria-label preserve toggle semantics; tooltip elaborates),
  placed on the same row as the chip.
- Extract the shared YieldMuteIcon glyph; add PopoverMenu and
  PriorityYieldList regression tests (the PopoverMenu test fails without
  the pointer-event seal).

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
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.

1 participant