Skip to content

YieldController MVP — per-PCH yield state with unified wire envelope#10555

Merged
tool4ever merged 22 commits intoCard-Forge:masterfrom
MostCromulent:YieldControllerMvp
May 3, 2026
Merged

YieldController MVP — per-PCH yield state with unified wire envelope#10555
tool4ever merged 22 commits intoCard-Forge:masterfrom
MostCromulent:YieldControllerMvp

Conversation

@MostCromulent
Copy link
Copy Markdown
Contributor

@MostCromulent MostCromulent commented Apr 28, 2026

Summary

Spin-off MVP carved from PR #9643 (YieldRework). Refactors yield state into a per-PlayerControllerHuman YieldController with a unified YieldUpdate wire envelope. Deletes 8 yield-related "ugly netplay fields" from PCH; collapses 5 fragmented yield ProtocolMethod entries into 2.

Architecture doc attached: yield-controller-architecture.md

Included

  • New YieldController class — owns markers, stack-yield, legacy autopass, per-card/ability auto-yield, trigger decisions, skip-phase prefs (one instance per PCH).
  • Unified YieldUpdate sealed envelope — single sendYieldUpdate / applyYieldUpdate ProtocolMethod pair carries every yield sync direction.
  • Atomic SeedFromClient(snapshot) at game start — replaces N-message replay loops with one wire message per controlled player. Covers per-card/ability auto-yields, autoYieldsDisabled, skip-phase prefs.
  • Phase markers (right-click desktop / long-press mobile on phase indicators) — auto-pass priority until the marked phase is reached, then clears.
  • "Yield to entire stack" stack-item context menu entry — resolves the entire stack including post-cancel additions.
  • Master interrupt semantics preserved (MagicStack.add() opponent-spell cancel clears legacy autopass; markers and stack-yield deliberately survive engine-driven cancels).
  • All ungated — no experimental flag.

Deferred to follow-up

  • Smart suggestions in InputPassPriority (PendingSuggestion enum)
  • Five interrupt prefs (YIELD_INTERRUPT_ON_*), YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, YIELD_AUTO_PASS_NO_ACTIONS
  • Speed Settings (YIELD_SKIP_PHASE_DELAY, YIELD_SKIP_RESOLVE_DELAY)
  • Mass-destruction / opponent-targeting interrupt evaluator
  • Host/client experimental-flag reconciliation
  • VYieldOptions mobile dialog and VYieldSettings Speed Settings section

The remainder of YieldRework rebases onto this MVP and ships those features in a follow-up PR.


🤖 Generated with Claude Code

…elope

Each PlayerControllerHuman now composes a YieldController instance that
owns all yield state for its player: legacy autoPassUntilEndOfTurn,
phase markers, stack-yield, per-card/ability auto-yield, trigger
decisions, and skip-phase prefs. The 8 yield-related "ugly netplay
fields" on PCH are deleted (7 migrated, 1 deferred); every yield
accessor collapses to a one-line delegation, removing the
isRemoteGuiProxy() fork from each method.

Five fragmented per-method ProtocolMethod entries collapse into two
unified entries (sendYieldUpdate CLIENT, applyYieldUpdate SERVER)
carrying a sealed YieldUpdate envelope with seven record cases. Game-
start chatter drops from N messages (one per saved auto-yield) to a
single SeedFromClient(snapshot) message; same path serves
reconnection.

New MVP features ungated:
- Phase markers via right-click (desktop) / long-press (mobile) on
  phase indicators. Auto-passes priority until the marked phase, then
  clears.
- "Yield to entire stack" stack-item context menu entry. Stack-yield
  resolves the entire stack including post-cancel additions; only ESC
  clears it.

Master interrupt behavior preserved: MagicStack.add() opponent-spell
cancel routes through PlayerController.autoPassCancel() ->
YieldController.cancelYield() (clears legacy + marker; stack-yield
deliberately survives). PhaseHandler turn-boundary cancel and
GameAction game-start cleanup unchanged.

Carve-out from PR <!-- -->Card-Forge#9643. Deferred features (smart suggestions,
interrupt prefs, auto-pass-no-actions, speed settings, host gating)
land in the YieldRework follow-up PR rebased onto this foundation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MostCromulent MostCromulent requested a review from tool4ever April 28, 2026 21:50
@MostCromulent MostCromulent added Enhancement New feature or request QOL Quality of Life labels Apr 28, 2026
Comment thread forge-gui/src/main/java/forge/player/PlayerControllerHuman.java Outdated
Wire path for skip-phase updates now goes
SetSkipPhase(turnPlayer, phase, skip) -> PCH.applyYieldUpdate switch
-> yieldController.setSkipPhase(...), bypassing the old per-method
IGameController.setUiShouldSkipPhase entry. PCH's override of that
method was no longer reachable.

NetGameController.setUiShouldSkipPhase stays — still called via
instanceof cast in NetworkGuiGame.pushSkipPhaseToControllers when
the GUI's PhaseLabel toggles fire — but no longer overrides an
interface method.

Also drops two YieldController accessors that had no callers:
getTriggerDecisions and getSkipPhases. They were defensively added
during the Phase 3 field migration as enumeration accessors, but
the only call sites that would have needed them (snapshot building)
read from GUI state and the AutoYieldStore directly. Single-key
getters (getTriggerDecision, isSkippingPhase) cover every actual
use. The PhaseType import in IGameController was load-bearing only
for the deleted setUiShouldSkipPhase signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java Outdated
Both PhaseLabel (desktop) and VPhaseIndicator.PhaseLabel (mobile) had
two click paths: a Runnable-callback "onToggled" for left-click skip-
phase wiring, and an inline right-click / long-press handler that
reached statically into CMatchUI / MatchController to dispatch yield-
marker updates.

Unify on the callback pattern. The view widgets now only expose
setOnRightClick / setOnLongPress hooks; CMatchUI.actuateMatchPreferences
and MatchController.actuateMatchPreferences register the marker logic
next to the existing setOnToggled wiring. PhaseLabel drops 5 imports
(PlayerView, YieldMarker, YieldUpdate, IGameController, CMatchUI) plus
its phaseOwner field; the now-redundant PhaseIndicator.setOwner push
and VField/VPlayerPanel call sites also go away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Hanmac Hanmac added the GUI label Apr 29, 2026
Comment thread forge-gui/src/main/java/forge/player/PlayerControllerHuman.java Outdated
Comment thread forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java Outdated
Comment thread forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java Outdated
Comment thread forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java Outdated
MostCromulent and others added 6 commits April 30, 2026 07:45
PCH and NetGameController had a parallel local-vs-remote-or-cache fork
in every yield method (shouldAutoYield, setShouldAutoYield,
getAutoYields, clearAutoYields, get/setDisableAutoYields,
shouldAlways*Trigger, setShouldAlways*Trigger). Each side decided where
to read or write based on isRemoteClient() and routed to either the
LobbyPlayer's persistent AutoYieldStore or YieldController's
per-controller cache fields.

YieldController now owns one AutoYieldStore resolved by activeStore():
LobbyPlayer's persistent store for host PCH controlling a local player,
or its own store as a per-game cache for host PCH controlling a remote
player and as a session-lifetime store for NetGameController. Tier and
install-mode logic that consulted FPref also moves into YieldController.

PCH's eleven yield methods become one-line delegators; NetGameController
loses its parallel yieldStore field and the duplicate FPref helpers. The
SetCardAutoYield and SetTriggerDecision arms in
NetGameController.applyYieldUpdate are dropped - server never pushes
those, and user-initiated setters bypass local-apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setMarker only suppressed pastTarget firing when priority was AT the
target at activation. Right-clicking a phase already past on the marker
owner's current turn left activationOnMarker = false, so the very next
shouldAutoYield tick fired and cleared the marker. Visible to clients
as the chevron disappearing instead of fast-forwarding to next turn.

Treat "priority at-or-past target on owner's turn" as one condition:
in both cases the marker must wait for the next turn's occurrence of
the target phase. The existing isPriorityAt helper (only used here)
becomes isPriorityAtOrPastMarker; setMarker uses it for both
hasLeftMarker and activationOnMarker. Other-player-turn activations
still take the existing path - pastTarget is gated by inMarkerOwnerTurn
in shouldAutoYield, so the misfire is impossible there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Hanmac's review on PR <!-- -->Card-Forge#10555, mirrors mobile's
VPhaseIndicator structure: one EnumMap<PhaseType, PhaseLabel> instead
of twelve hand-named fields plus their getters and the getLabelFor
switch. populatePhase becomes twelve addPhaseLabel calls (caption,
phase, tooltip key); resetPhaseButtons becomes a single loop.
allLabels returns Iterable to match mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per tool4ever's review on PR <!-- -->Card-Forge#10555: the ESC clearing logic
duplicated across desktop's VPrompt belongs in YieldController, and
mobile MatchScreen.keyDown was missing it entirely.

YieldController.clearActiveYields(local, controller) takes a player and
an IGameController, clears any active marker / legacy autopass /
stack-yield, and returns whether anything was cleared so callers can
suppress the ESC fallthrough and refresh the chevron UI. Marker and
stack-yield go over the wire via controller.sendYieldUpdate; legacy
autopass is local to host PCH (still gated by the existing instanceof
PlayerControllerHuman check). Persistent per-card auto-yields are
unaffected.

Desktop VPrompt's keyPressed handler collapses to a single helper
call. Mobile MatchScreen.keyDown's ESCAPE arm gains the same prelude
before its existing btnCancel trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per tool4ever's review on PR <!-- -->Card-Forge#10555: the ESC handler was
reaching for the global CMatchUI.getActive() static when it could just
read controller.getMatchUI() off VPrompt's own CPrompt field. The
controller-scoped accessor is also more correct - the static lookup
could in principle return a different instance than the one this
prompt belongs to. Drops the redundant null check that was guarding
against the static returning null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java Outdated
Comment thread forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java Outdated
Comment thread forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java Outdated
tool4EvEr and others added 5 commits April 30, 2026 17:29
Per PR Card-Forge#10555 review (tool4ever, r3168957452): the server→client side of the
unified yield envelope only ever carried ClearMarker, and that's derivable on
the client from cached marker + phase events. Marker-fire detection moves into
YieldController.checkAndClearMarker(GameView) — pure derivation called both
from the host's shouldAutoYield priority loop (so the host stops auto-passing
promptly at the target phase) and from an EDT phase-event listener (so the
chevron + auto-pass prompt refresh on both host and remote client without a
wire round-trip).

Wire surface drops from 2 ProtocolMethod entries to 1: removes
ProtocolMethod.applyYieldUpdate, IGuiGame.applyYieldUpdate, and the
RemoteClientGuiGame/NetworkGuiGame overrides; AbstractGuiGame.applyYieldUpdate
becomes checkMarkerAutoClear. YieldUpdate envelope itself stays as the unified
shape for client→host messages. Marker mutators synchronized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per PR Card-Forge#10555 review (tool4ever, r3169048394): the client-side
yieldController.setSkipPhase write in setUiShouldSkipPhase is unread —
the client GUI checks PhaseLabel state directly, and the host's auto-pass
reads from the host PCH's own yieldController. Only the wire send to host
is load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VPrompt (desktop) and MatchScreen (mobile) ESC handlers were clearing
active yields before invoking the prompt's cancel button. If a trigger
confirm or other input prompt was open, ESC silently dropped the yield
and left the prompt waiting -- breaking the user's "ESC = No / cancel
this action" expectation.

Flip the precedence: if btnCancel is in a contextually meaningful state
(enabled and not the no-op "End Turn" label with UI_ALLOW_ESC_TO_END_TURN
off), ESC clicks it and returns. Only when ESC would otherwise be a
no-op does it fall back to clearing marker / legacy autopass /
stack-yield. Net result is a strict superset of master ESC semantics,
plus yield-clear in the slots where master already did nothing.

Addresses review feedback on PR <!-- -->Card-Forge#10555.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Host's local chevron stayed drawn after the priority loop fired the marker:
shouldAutoYield's checkAndClearMarker call discarded its return value. Now
refreshYieldUi runs when the priority-loop path actually clears the marker;
the EDT phase-event listener still handles the race-win case.

Setting a marker on a past phase from the remote client never showed the
chevron: NetGameController constructs YieldController with a null owner, so
setMarker's at-or-past check returned false, activationOnMarker defaulted to
false, and the next phase event made checkAndClearMarker fire prematurely.
Pass the at-or-past flag via SetMarker (computed at click time by the UI),
so client cache and host PCH initialize identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java Outdated
Comment thread forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java Outdated
MostCromulent and others added 7 commits May 2, 2026 07:55
Per tool4ever review (r3170857314): host-side cancel routing stays at
master's InputLockUI.selectButtonCancel -- uniform regardless of which
Input is active. That's correct on the host, but it's not sufficient by
itself in network play. The remote client caches its own YieldController
for chevron rendering, and round-2 deleted the server->client
applyYieldUpdate propagation path, so host-side state changes no longer
reach the client automatically. And on the host, a cancel can route to
a non-LockUI Input (e.g. InputPayMana) which never reaches the
autoPassCancel-for-all-players loop in InputLockUI.selectButtonCancel.
Four client-side fixes were needed:

- Yield prompt persists across opponent-turn waits: updatePromptForAwait
  and the 1s elapsed-time tick append "Waiting for opponent..." below
  the yield text instead of replacing it.
- Yield-cancel detection by exact prompt match (lastPromptMessage)
  instead of cancel-button-label heuristic, so InputPayMana's "Cancel"
  doesn't disarm a marker.
- Cancel-button click sends ClearMarker/StackYield(false) via
  sendYieldUpdate -- self-applies locally (chevron) and ships to host
  so server-side state clears regardless of which Input is active.
- Clearing the marker (cancel, right-click toggle-off, host fast-forward
  past target) refreshes the prompt locally via a new
  refreshPromptAfterLocalYieldClear() so stale "Yielding until..." text
  drops immediately.

Adjacent: FDialog only consumes ESC when modal/focused;
PCH.applyYieldUpdate triggers updateAutoPassPrompt on yield activation;
lblYieldingUntilPhaseFmt apostrophe escape ('s -> ''s) for MessageFormat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tool4ever review (r3172286642): the 12 htmlPhase*Tooltip strings all
followed the same "<html>Phase: <name><br>Click to toggle.</html>"
template. Replace them with a single htmlPhaseTooltipFmt that takes
PhaseType.nameForUi as the format argument. PhaseIndicator.addPhaseLabel
loses its tooltipKey parameter and builds the tooltip inline.

The shared text also documents the new right-click-to-fast-forward
behavior introduced earlier on this branch.

Removed 12 keys from each of the 9 .properties files. New key only
added to en-US -- Localizer falls back to English when missing in the
active locale, so translators can fill in localized versions later
without blocking this consolidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cancel becomes host-driven: clears all three yield modes per-PCH via
the host's InputLockUI; server→client applyYieldUpdate wire restored
for chevron sync, with forward-compat for YieldRework's interrupt-
driven cancel records (mass-destruction, opponent-targeting). Per-PCH
instead of master's all-players iteration -- in netplay that would
let a remote client's cancel disarm the host's own auto-pass.

- Restore ProtocolMethod.applyYieldUpdate (Mode.SERVER) on the
  existing YieldUpdate sealed envelope; MVP ships ClearMarker /
  StackYield. RemoteClientGuiGame override sends over wire;
  AbstractGuiGame default routes to local IGameController
- InputLockUI.selectButtonCancel: per-PCH clear of all three modes;
  autoPassCancel() runs first to keep its mayAutoPass gate satisfied
- Drop client-side machinery: lastPromptMessage, doShowPromptMessage
  abstract rename, isYieldPromptShowing, clearLocalYieldsForCancel,
  refreshPromptAfterLocalYieldClear, EDT phase listener
  checkMarkerAutoClear, actCancel wrappers on desktop CPrompt +
  mobile MatchScreen
- YieldController.checkAndClearMarker now private and unsynchronized
  (game-thread only via shouldAutoYield); setMarker/clearMarker keep
  synchronized
- updateAutoPassPrompt restarts the elapsed-time timer so "Yielding
  until X / Waiting for {opponent} (Xs)" stays visible during slow
  opponent decisions; per-tick wire cost is zero
- Rename misnamed YieldController.autoPassUntilEOT() to
  autoPassUntilStackEmpty(); add autoPassUntilEndOfTurn() for the
  legacy field; rewrite currentYieldMessage with explicit branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@tool4ever tool4ever left a comment

Choose a reason for hiding this comment

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

While I'm not fully happy with all data flows yet it's arguably cleaner than before

@tool4ever tool4ever merged commit d33f761 into Card-Forge:master May 3, 2026
2 checks passed
MostCromulent added a commit to MostCromulent/forge that referenced this pull request May 3, 2026
Replace boolean UI_PRESELECT_PREVIOUS_ABILITY_ORDER with a tri-state
UI_REORDER_SIMULTANEOUS_ABILITY_MODE (Off / Preselect / Auto-apply).
In Auto mode, when the same simultaneous-ability combo fires again
within a match the saved order is applied silently — no dialog. A
"Clear Ability Orders" entry on the in-match Game menu (visible only
in Auto mode) wipes the saved orders so the dialog reappears.

The clear action rides on the YieldUpdate envelope from <!-- -->Card-Forge#10555 as a
new ClearAbilityOrders record, so remote clients reach the host-side
PCH's orderedSALookup without a dedicated ProtocolMethod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MostCromulent added a commit to MostCromulent/forge that referenced this pull request May 3, 2026
Restructure trigger Always-Yes / Always-No persistence to fit
the YieldController architecture introduced in PR Card-Forge#10555.

- AutoYieldStore: replace int-keyed per-game trigger map with
  EnumMap<Tier, Map<String, TriggerDecision>> + per-store
  triggerDecisionsDisabled flag, mirroring the yield side.
- PersistentYieldStore renamed to PersistentAutoDecisionStore
  and extended with trigger.accept.<key>= / trigger.decline.<key>=
  schema lines (additive, backwards compatible with existing
  auto-yields.dat files).
- YieldStateSnapshot: split trigger decisions by scope (card vs
  ability), add autoTriggersDisabled flag.
- YieldUpdate.TriggerDecision reshaped to (storageKey, decision,
  abilityScope), mirroring CardAutoYield. New SetDisableYields
  and SetDisableTriggers records carry runtime disable toggles
  through the unified envelope (incidentally fixes a master bug
  where remote NetGameController.setDisableAutoYields never
  reached the host's cache).
- YieldController: tier-aware String-keyed trigger API
  (shouldAlways*Trigger / setAlways*Trigger / applyTriggerDecision
  FromWire / getAutoTriggers / get+setDisableAutoTriggers)
  paralleling the yield API. activeTriggerTier reads a separate
  UI_AUTO_TRIGGER_MODE preference.
- ForgeConstants: AUTO_TRIGGER_PER_CARD/_PER_ABILITY/_SESSION/
  _INSTALL constants. UI_AUTO_TRIGGER_MODE defaults to Per Card
  to preserve today's per-instance trigger behavior.
- IGameController, NetGameController, PlayerControllerHuman:
  String-keyed trigger setters, dispatcher branches for the new
  YieldUpdate records.
- Trigger: toString(active, includeRemembered) overload + stable
  getYieldKey() helper paralleling SpellAbility.yieldKey().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request GUI QOL Quality of Life

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants