From 86c2e3763e001195bc830d59047fc160e0021a39 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 5 May 2026 09:38:39 +0930 Subject: [PATCH 01/36] =?UTF-8?q?YieldReworkV2=20=E2=80=94=20configurable?= =?UTF-8?q?=20interrupts,=20APINA,=20smart=20suggestions,=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the per-PCH YieldController MVP shipped in master. Adds the deferred YieldRework feature set on top of that architecture without introducing new ProtocolMethod entries — everything rides the existing YieldUpdate envelope. Features - Six configurable interrupt prefs that control when an active autopass (EOT, marker) is cancelled: OPPONENT_SPELL, ATTACKERS, TARGETING, MASS_REMOVAL, TRIGGERS, REVEAL. First two default ON to preserve master's hardcoded behavior; the rest default OFF. - APINA (Auto-Pass If No Actions) — per-tick predicate gated by the AvailableActions heuristic in forge-ai, with configurable timeout. - Smart suggestions — InputPassPriority offers a stack-yield prompt when there are spells on the stack and no playable response, and a no-actions marker prompt on the player's own turn with no playable cards. Each has a configurable decline scope (NEVER / ALWAYS / STACK / TURN). - Speed settings — opt-in skip of inter-phase and post-resolve delays. - Desktop UI: VYield dock-tab panel (Auto-Pass toggle + Settings buttons) and VYieldSettings dialog. Three configurable shortcuts: Ctrl+Y opens settings, F2 toggles APINA, ESC clears the active yield. - Mobile UI: VYieldOptions scrollable settings dialog + VGameMenu entries for Yield Options and the Auto-Pass toggle. Architecture - All new state lives on per-PCH YieldController; each client's preferences govern only that client's host-side proxy. - Three new YieldUpdate variants (SetAutoPassUntilEndOfTurn, SetYieldBoolPref, SetYieldStringPref) plus pref-overlay extensions to the SeedFromClient snapshot. Zero new ProtocolMethod entries. - Single-yield invariant: at most one of EOT/marker/stack-yield is active at a time (setters enforce). Stack-yield is deliberately immune to event-driven interrupts — only stack-empty turns it off, since its whole point is "ride through stack additions." - YieldController.apply(YieldUpdate) is the single dispatch point for wire envelopes; PCH and NetGameController applyYieldUpdate are thin delegators. - Type-safe domain enums: DeclineScope (NEVER/ALWAYS/STACK/TURN) and SuggestionType (STACK_YIELD/NO_ACTIONS, owns allowed scopes + scope FPref) replace the previous stringly-typed code. - PCH.tryAutoPassNow runs after every yield-state change and re-evaluates mayAutoPass at the current input, so toggles fire on the current prompt (no priority-window lag). Hardcoded interrupt sites deleted from MagicStack, PCH.declareAttackers, and HostedMatch — the event-driven handler covers the same conditions, now governed by per-PCH prefs. Also lower DragCell minimum height to 50px so the dragged-out yield-options tab can shrink to fit a single row of buttons. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/advanced-yield-options.md | 137 +++++++ .../main/java/forge/ai/AvailableActions.java | 83 ++++ .../java/forge/game/player/PlayerView.java | 8 + .../main/java/forge/game/zone/MagicStack.java | 8 - .../forge/trackable/TrackableProperty.java | 1 + .../java/forge/control/KeyboardShortcuts.java | 40 ++ .../main/java/forge/gui/framework/EDocID.java | 1 + .../forge/gui/framework/SResizingUtil.java | 2 +- .../java/forge/screens/match/CMatchUI.java | 21 + .../forge/screens/match/VYieldSettings.java | 270 +++++++++++++ .../screens/match/controllers/CYield.java | 96 +++++ .../forge/screens/match/menus/GameMenu.java | 10 + .../forge/screens/match/views/VPrompt.java | 21 +- .../forge/screens/match/views/VYield.java | 101 +++++ .../src/forge/screens/match/MatchScreen.java | 24 +- .../forge/screens/match/views/VGameMenu.java | 11 + .../screens/match/views/VYieldOptions.java | 293 ++++++++++++++ forge-gui/res/defaults/match.xml | 1 + forge-gui/res/languages/en-US.properties | 39 ++ .../gamemodes/match/AbstractGuiGame.java | 1 + .../forge/gamemodes/match/DeclineScope.java | 24 ++ .../forge/gamemodes/match/HostedMatch.java | 5 +- .../forge/gamemodes/match/SuggestionType.java | 29 ++ .../gamemodes/match/YieldController.java | 366 +++++++++++++++++- .../gamemodes/match/YieldStateSnapshot.java | 5 +- .../forge/gamemodes/match/YieldUpdate.java | 10 + .../gamemodes/match/input/InputLockUI.java | 19 +- .../match/input/InputPassPriority.java | 190 +++++++++ .../net/client/NetGameController.java | 23 +- .../gui/control/FControlGameEventHandler.java | 29 +- .../forge/interfaces/IGameController.java | 5 + .../properties/ForgePreferences.java | 18 + .../forge/player/PlayerControllerHuman.java | 105 +++-- 33 files changed, 1908 insertions(+), 88 deletions(-) create mode 100644 docs/advanced-yield-options.md create mode 100644 forge-ai/src/main/java/forge/ai/AvailableActions.java create mode 100644 forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java create mode 100644 forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java create mode 100644 forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java create mode 100644 forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md new file mode 100644 index 00000000000..82c1f93c2a9 --- /dev/null +++ b/docs/advanced-yield-options.md @@ -0,0 +1,137 @@ +## Contents +- [How to Access](#how-to-access) +- [Auto-Pass](#auto-pass) +- [Yield markers](#yield-markers) +- [Yield Settings Menu](#yield-settings-menu) + - [Yield Interrupt Settings](#yield-interrupt-settings) + - [Automatic Yield Suggestions](#automatic-yield-suggestions) + - [Speed Options](#speed-options) +- [Troubleshooting](#troubleshooting) +- [Network Play](#network-play) + +# Advanced Yield Options +The standard priority system in Forge can involve dozens of priority passes every turn. This can can slow down the game, particularly in multiplayer games like Commander, where one player's delay responding to priority halts the game for everybody else. + +Forge offers a range of **Advanced Yield Options** to: + +- enable players to automatically yield when there is no available action they can take. +- give players the ability to yield until a specific phase is reached, without responding to priority passes in the meantime. +- configure yield interrupt conditions, so you'll always get control back when something important happens (e.g. you are attacked or targeted by a spell). +- provide smart suggestions to enable yield if there are no useful actions you can take (e.g. it is another player's turn and you have no mana or playable cards). + +These features are highly configurable through the **Yield Settings** dialog, and can be set up to suit your own gameplay preferences. + +## How to Access + +- **Desktop:** The **Yield Options** tab appears in your match UI in the prompt panel. You can also open the Game menu > **Yield Settings**, or press Ctrl+Y. +- **Mobile:** open the in-match Game menu > **Yield Options**. + +## Auto-Pass + +**Auto-Pass** is a persistent toggle (F2 on desktop, or the Auto-Pass button) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. + +**How it works:** +- When enabled, Forge scans your hand, battlefield, and external zones (graveyard, exile, command) for castable spells, playable lands, and activatable abilities. +- If you have any available action, you keep priority as usual. +- If you have no available action, Forge passes priority on your behalf without prompting. +- The button label reflects the state (`Auto-Pass: ON` / `Auto-Pass: OFF`). + +**Interaction with interrupts:** By default, Auto-Pass ignores your interrupt settings — it keeps passing as long as you have no actions, regardless of attackers, opponent spells, mass-removal, etc. Enable **Auto-pass respects interrupts** in the Yield Interrupt Settings section if you want interrupts to break Auto-Pass too. + +**Persistence:** Unlike yield markers, Auto-Pass does not end on a game event. It stays active until you toggle it off. + +**Performance and timeout:** The action-availability scan can be expensive in complex board states. The scan is subject to the **Auto-pass calculation timeout** setting in the Yield Settings dialog. On timeout the system prompts you instead of auto-passing, so a false positive means an extra prompt rather than a long stall. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). Set your own value in the Yield Settings dialog to override. + +> [!NOTE] +> **The Auto-pass AI is not perfect.** It is designed to avoid false negatives (passing priority when there is action you can take) as much as possible. There may be times it produces a false positive (giving you priority when there is nothing you can do). Use with appropriate caution. + +## Yield markers + +A **yield marker** tells Forge to auto-pass priority until a specific phase is reached. Markers are set directly on the phase indicator strip in the match UI. + +**Setting a marker:** +- **Desktop:** right-click the phase indicator cell for the phase you want to yield to. +- **Mobile:** long-press the phase indicator cell. + +A fast-forward symbol will appear on the targeted cell to show the marker is active. The prompt area also describes what phase you are yielding to. Forge then auto-passes priority on your behalf until that phase is reached, at which point the marker clears automatically and you regain priority. + +**Per-(player, phase) precision:** Each phase indicator cell is distinct per player. Right-clicking your own End Step yields to *your* End Step; right-clicking an opponent's End Step yields to *that opponent's* End Step. In multiplayer (e.g. four-player Commander) this lets you express things like "yield until that opponent's End Step" without affecting how you respond to the other opponents' end steps. + +**Cancelling:** +- Right-click (or long-press) the marker again to cancel it. +- Press **ESC** (desktop) to cancel any active marker. +- An enabled interrupt firing (see [Yield Interrupt Settings](#yield-interrupt-settings)) cancels the marker and hands priority back to you. + +**Re-targeting:** Right-clicking (or long-pressing) a different phase indicator while a marker is active moves the marker to the new cell. Only one marker is active at a time. + +## Yield Settings Menu + +The **Yield Settings** dialog is the central configuration UI for yield behavior. It's accessible from: +- **Desktop:** the **...** button on the Yield Options panel, the Game menu > **Yield Settings** entry, or Ctrl+Y. +- **Mobile:** Game menu > **Yield Options**. + +The dialog has three sections: + +### Yield Interrupt Settings + +Yield markers and end-of-turn yield automatically cancel when important game events occur. (Stack-yield is exempt — its purpose is to watch the stack resolve, so opponent spells hitting the stack do not cancel it.) + +You can decide which game events interrupt a yield: + +| Interrupt | Default | Description | +|-----------|---------|-------------| +| **Attackers declared against you** | ON | Triggers when creatures attack you specifically (not when other players are attacked). | +| **Opponent casts any spell** | ON | Triggers on opponent spells and activated abilities (not triggered abilities). | +| **You or your permanents targeted** | OFF | Triggers when a spell/ability targets you or something you control. | +| **Mass removal spell cast** | OFF | Triggers when an opponent casts a board wipe (DestroyAll / DamageAll / SacrificeAll / ChangeZoneAll spell). | +| **Triggered abilities on stack** | OFF | Triggers when triggered abilities are on the stack. | +| **Cards revealed or choices made** | OFF | Triggers when reveal dialogs / non-trivial value notifications fire. | + +In addition to the six interrupt toggles, this section contains: + +- **Auto-pass respects interrupts** (default OFF). When OFF, Auto-Pass keeps running through every interrupt condition — useful since the whole point of Auto-Pass is to skip prompts when you have no actions. When ON, the same interrupts that cancel yield markers will also cancel Auto-Pass and hand priority back to you. + +**Multiplayer note:** The attackers interrupt is scoped to you specifically. If Player A attacks Player B, your yield will not be interrupted. + +### Automatic Yield Suggestions + +When the system detects situations where you likely cannot take action, it can prompt you with a yield suggestion. Each suggestion type has a dropdown controlling its decline behavior: + +| Suggestion | When it appears | Suggested action | Decline scope options | +|------------|-----------------|------------------|-----------------------| +| **Can't respond to stack** | You have no instant-speed responses available | Stack yield (auto-pass until stack empties) | Never (default) / Always / Once per stack / Once per turn | +| **No actions available** | No playable cards or activatable abilities (not your turn, stack empty) | Yield to your next turn | Never (default) / Always / Once per turn | + +**Decline scope options:** +- **Never:** suggestion is disabled entirely (never shown). +- **Always:** suggestion re-appears on the next priority pass, even if just declined. +- **Once per stack:** declining suppresses the suggestion until the current stack resolves. A new stack will re-prompt. (Only available for "Can't respond to stack".) +- **Once per turn:** declining suppresses the suggestion for the rest of the current turn. + +In addition to the per-suggestion dropdowns, this section contains two global suppression toggles: + +- **Suppress on own turn** (default ON): suppress suggestions during your own turn, when you typically want to take actions. Suggestions are always suppressed on your first turn regardless of this setting, since you won't have any lands or mana yet. +- **Suppress immediately after yield ends** (default ON): suppress suggestions for one priority pass when a yield expires or is interrupted, giving you time to assess the game state before deciding whether to re-yield. + +### Speed Options + +- **Auto-pass calculation timeout:** The amount of time in milliseconds the AI has to calculate whether you have any available actions and whether you should auto-pass. If the timeout is reached auto-pass will return false and hand you priority as a safeguard. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). +- **Skip delay between phases:** skip Forge's default 200ms delay between each phase resolving. +- **Skip delay when stack resolves:** skip Forge's default 400ms delay between items on the stack resolving. + +## Troubleshooting + +### Yield marker doesn't appear when right-clicking / long-pressing a phase indicator +- Markers cannot be set during pre-game, mulligan, or cleanup phases. + +### Yield clears unexpectedly +- Check interrupt settings in the Yield Settings dialog. +- A marker also clears automatically the moment its target phase is reached. + +### Smart suggestions not appearing +- Verify the suggestion's decline scope is not set to "Never" in the Yield Settings dialog. +- Suggestions don't appear if you're already yielding. +- If you declined a suggestion, check the decline scope to understand when it will re-appear. + +## Network Play +- Each player controls their own yield preferences. Your yield marker, stack-yield state, interrupt settings, and decline-scope choices apply to you only and propagate across the network — they do not affect other players. The host does not impose its own preferences on connected clients. \ No newline at end of file diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java new file mode 100644 index 00000000000..0539743759e --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -0,0 +1,83 @@ +package forge.ai; + +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; +import org.tinylog.Logger; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +// Heuristic: does the player have any playable action this priority window? +// Bounded by timeoutMs; returns true on expiry (false-positive — player is prompted). +public final class AvailableActions { + + private static final Comparator BY_CMC_ASC = Comparator.comparingInt(Card::getCMC); + + private AvailableActions() {} + + public static boolean compute(Player player, long timeoutMs) { + long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; + + for (Card card : sortedCardsIn(player, ZoneType.Hand)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (sa.isSpell()) { + if (canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } else if (sa.isLandAbility()) { + return true; + } + } + } + + // Not sorted: activation costs are per-ability, not the permanent's CMC. + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } + } + + for (Card card : sortedCardsIn(player, ZoneType.Flashback)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } + } + + return false; + } + + // Sort cheap cards first so cheap-to-validate matches early-exit + private static Iterable sortedCardsIn(Player player, ZoneType zone) { + Iterable cards = player.getCardsIn(zone); + List copy = new ArrayList<>(); + cards.forEach(copy::add); + if (copy.size() < 2) return copy; + copy.sort(BY_CMC_ASC); + return copy; + } + + private static boolean canAfford(SpellAbility sa, Player player) { + if (sa.getPayCosts() == null || !sa.getPayCosts().hasManaCost()) { + return true; + } + return ComputerUtilMana.canPayManaCost(sa, player, 0, false); + } + + private static boolean checkTimeout(long deadlineNanos, long timeoutMs) { + if (System.nanoTime() < deadlineNanos) { + return false; + } + Logger.warn("AvailableActions: heuristic timed out after {}ms; returning true.", timeoutMs); + return true; + } +} diff --git a/forge-game/src/main/java/forge/game/player/PlayerView.java b/forge-game/src/main/java/forge/game/player/PlayerView.java index daddba99a62..c8af45eab6b 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -212,6 +212,14 @@ public void setHasLost(final boolean val) { set(TrackableProperty.HasLost, val); } + public boolean hasAvailableActions() { + return get(TrackableProperty.HasAvailableActions); + } + + public void setHasAvailableActions(boolean value) { + set(TrackableProperty.HasAvailableActions, value); + } + public int getAvatarLifeDifference() { return (int)get(TrackableProperty.AvatarLifeDifference); } diff --git a/forge-game/src/main/java/forge/game/zone/MagicStack.java b/forge-game/src/main/java/forge/game/zone/MagicStack.java index 6efcc01cf8c..2665b112758 100644 --- a/forge-game/src/main/java/forge/game/zone/MagicStack.java +++ b/forge-game/src/main/java/forge/game/zone/MagicStack.java @@ -324,14 +324,6 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) { return; } - //cancel auto-pass for all opponents of activating player - //when a new non-triggered ability is put on the stack - if (!sp.isTrigger()) { - for (final Player p : activator.getOpponents()) { - p.getController().autoPassCancel(); - } - } - if (sp instanceof AbilityStatic || (sp.isTrigger() && sp.getTrigger().getOverridingAbility() instanceof AbilityStatic)) { AbilityUtils.resolve(sp); // AbilityStatic should do nothing below diff --git a/forge-game/src/main/java/forge/trackable/TrackableProperty.java b/forge-game/src/main/java/forge/trackable/TrackableProperty.java index b8c244139e1..06b3fc9fa7e 100644 --- a/forge-game/src/main/java/forge/trackable/TrackableProperty.java +++ b/forge-game/src/main/java/forge/trackable/TrackableProperty.java @@ -234,6 +234,7 @@ public enum TrackableProperty { HasPriority(TrackableTypes.BooleanType, FreezeMode.IgnoresFreeze), AvatarLifeDifference(TrackableTypes.IntegerType, FreezeMode.IgnoresFreeze), HasLost(TrackableTypes.BooleanType), + HasAvailableActions(TrackableTypes.BooleanType), //SpellAbility HostCard(TrackableTypes.CardViewType), diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 526d8d93d46..fd6de4a604b 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -18,14 +18,18 @@ import org.apache.commons.lang3.StringUtils; import forge.Singletons; +import forge.game.player.PlayerView; import forge.game.spellability.StackItemView; +import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.EDocID; +import forge.interfaces.IGameController; import forge.gui.framework.SDisplayUtil; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.screens.home.settings.VSubmenuPreferences.KeyboardShortcutField; import forge.screens.match.CMatchUI; +import forge.screens.match.VYieldSettings; import forge.toolbox.special.CardZoomer; import forge.util.Localizer; import forge.view.KeyboardShortcutsDialog; @@ -261,6 +265,39 @@ public void actionPerformed(final ActionEvent e) { } }; + final Action actYieldOptions = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + SwingUtilities.invokeLater(() -> new VYieldSettings(matchUI).showDialog()); + } + }; + + final Action actAutoPassNoActions = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null || matchUI.getCYield() == null) { return; } + matchUI.getCYield().toggleAutoPass(); + } + }; + + final Action actCancelYield = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + IGameController ctrl = matchUI.getGameController(); + PlayerView local = matchUI.getCurrentPlayer(); + if (ctrl == null || local == null || ctrl.getYieldController() == null) { return; } + if (!ctrl.getYieldController().isYieldActive()) { return; } + ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); + } + }; + final Localizer localizer = Localizer.getInstance(); //========== Instantiate shortcut objects and add to list. list.add(new Shortcut(FPref.SHORTCUT_SHOWSTACK, localizer.getMessage("lblSHORTCUT_SHOWSTACK"), actShowStack, am, im)); @@ -274,6 +311,9 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWTARGETING, localizer.getMessage("lblSHORTCUT_SHOWTARGETING"), actTgtOverlay, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_YES, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_YES"), actAutoYieldAndYes, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_NO, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_NO"), actAutoYieldAndNo, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_OPTIONS, localizer.getMessage("lblSHORTCUT_YIELD_OPTIONS"), actYieldOptions, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_AUTO_PASS, localizer.getMessage("lblSHORTCUT_YIELD_AUTO_PASS"), actAutoPassNoActions, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_CANCEL, localizer.getMessage("lblSHORTCUT_YIELD_CANCEL"), actCancelYield, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_RECORD, localizer.getMessage("lblSHORTCUT_MACRO_RECORD"), actMacroRecord, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_NEXT_ACTION, localizer.getMessage("lblSHORTCUT_MACRO_NEXT_ACTION"), actMacroNextAction, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CARD_ZOOM, localizer.getMessage("lblSHORTCUT_CARD_ZOOM"), actZoomCard, am, im)); diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java index dabd0d5968e..72dcf8d68c7 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java @@ -94,6 +94,7 @@ public enum EDocID { REPORT_COMBAT (), REPORT_DEPENDENCIES (), REPORT_LOG (), + REPORT_YIELD (), DEV_MODE (), BUTTON_DOCK (), diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java b/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java index 0575fea6ac9..e2642a5468b 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java @@ -48,7 +48,7 @@ public final class SResizingUtil { /** Minimum cell width. */ public static final int W_MIN = 100; /** Minimum cell height. */ - public static final int H_MIN = 75; + public static final int H_MIN = 50; private static final MouseListener MAD_RESIZE_X = new MouseAdapter() { @Override diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index d414fae2253..ca226685ac5 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -105,6 +105,7 @@ import forge.screens.match.controllers.CDev; import forge.screens.match.controllers.CDock; import forge.screens.match.controllers.CLog; +import forge.screens.match.controllers.CYield; import forge.screens.match.controllers.CPrompt; import forge.screens.match.controllers.CStack; import forge.screens.match.menus.CMatchUIMenus; @@ -175,6 +176,8 @@ public final class CMatchUI private final CLog cLog = new CLog(this); private final CPrompt cPrompt = new CPrompt(this); private final CStack cStack = new CStack(this); + private final CYield cYield = new CYield(this); + public CYield getCYield() { return cYield; } private int nextNotifiableStackIndex = 0; public CMatchUI() { @@ -194,6 +197,7 @@ public CMatchUI() { this.myDocs.put(EDocID.REPORT_COMBAT, cCombat.getView()); this.myDocs.put(EDocID.REPORT_DEPENDENCIES, cDependencies.getView()); this.myDocs.put(EDocID.REPORT_LOG, cLog.getView()); + this.myDocs.put(EDocID.REPORT_YIELD, cYield.getView()); this.myDocs.put(EDocID.DEV_MODE, getCDev().getView()); this.myDocs.put(EDocID.BUTTON_DOCK, getCDock().getView()); } @@ -874,6 +878,7 @@ public void updatePlayerControl() { FloatingZone.registerZoneDocs(this, getLocalPlayers()); SLayoutIO.loadLayout(null); FloatingZone.pruneUnparentedDocks(); + ensureNewDocsVisible(); view.populate(); final PlayerZoneUpdates zones = new PlayerZoneUpdates(); for (final PlayerView p : sortedPlayers) { @@ -882,6 +887,22 @@ public void updatePlayerControl() { updateZones(zones); } + /** + * Adds new EDocIDs introduced after the user's saved layout was created + * so existing users see them without resetting their layout. Currently: + * REPORT_YIELD → prompt cell (the cell hosting REPORT_MESSAGE). + */ + private void ensureNewDocsVisible() { + final IVDoc yieldDoc = EDocID.REPORT_YIELD.getDoc(); + if (yieldDoc != null && yieldDoc.getParentCell() == null) { + final IVDoc promptDoc = EDocID.REPORT_MESSAGE.getDoc(); + final DragCell promptCell = promptDoc == null ? null : promptDoc.getParentCell(); + if (promptCell != null) { + promptCell.addDoc(yieldDoc); + } + } + } + @Override public void disableOverlay() { showOverlay = false; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java new file mode 100644 index 00000000000..e8385139435 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java @@ -0,0 +1,270 @@ +package forge.screens.match; + +import forge.Singletons; +import forge.gui.UiCommand; +import forge.interfaces.IGameController; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; + +import java.util.Set; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.FButton; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FLabel; +import forge.toolbox.FTextField; +import forge.util.Localizer; +import forge.view.FDialog; + +import javax.swing.JSeparator; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; +import javax.swing.text.PlainDocument; + +/** + * Dialog for configuring yield interrupt conditions and automatic suggestions. + */ +@SuppressWarnings("serial") +public class VYieldSettings extends FDialog { + private static final int PADDING = 10; + private static final int ROW_HEIGHT = 24; + private static final int SECTION_GAP = 12; + private static final int BUTTON_WIDTH = 100; + private static final int BUTTON_HEIGHT = 26; + private static final int DROPDOWN_WIDTH = 120; + private static final int BODY_FONT_SIZE = 12; + + private static void setBodyFont(java.awt.Component c) { + c.setFont(c.getFont().deriveFont((float) BODY_FONT_SIZE)); + } + + private final CMatchUI matchUI; + + public VYieldSettings(CMatchUI matchUI) { + super(); + this.matchUI = matchUI; + final Localizer localizer = Localizer.getInstance(); + final ForgePreferences prefs = FModel.getPreferences(); + + setTitle(localizer.getMessage("lblYieldSettings")); + + int width = Math.min(Singletons.getView().getFrame().getWidth() * 2 / 3, 480); + int w = width - 2 * PADDING; + int x = PADDING; + int y = PADDING; + + FLabel lblInterrupt = new FLabel.Builder().text(localizer.getMessage("lblInterruptSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblInterrupt, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT - 4; + + y = addDescription(x, y, w, localizer.getMessage("lblInterruptSettingsDesc")); + + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTriggers"), FPref.YIELD_INTERRUPT_ON_TRIGGERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblAutoPassRespectsInterrupts"), FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, prefs); + + y += SECTION_GAP; + JSeparator sep = new JSeparator(); + add(sep, x, y, w, 2); + y += 2 + SECTION_GAP; + + FLabel lblSuggestions = new FLabel.Builder().text(localizer.getMessage("lblAutomaticSuggestions")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSuggestions, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT - 4; + + y = addDescription(x, y, w, localizer.getMessage("lblAutomaticSuggestionsDesc")); + + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestStackYield"), + FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, + SuggestionType.STACK_YIELD.allowedScopes(), + prefs); + + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestNoActions"), + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, + SuggestionType.NO_ACTIONS.allowedScopes(), + prefs); + + y += SECTION_GAP; + + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressOnOwnTurn"), FPref.YIELD_SUPPRESS_ON_OWN_TURN, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressAfterYield"), FPref.YIELD_SUPPRESS_AFTER_END, prefs); + + y += SECTION_GAP; + JSeparator sep2 = new JSeparator(); + add(sep2, x, y, w, 2); + y += 2 + SECTION_GAP; + + FLabel lblSpeed = new FLabel.Builder().text(localizer.getMessage("lblSpeedSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSpeed, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT + 2; + + y = addTimeoutField(x, y, w, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipPhaseDelay"), FPref.YIELD_SKIP_PHASE_DELAY, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipResolveDelay"), FPref.YIELD_SKIP_RESOLVE_DELAY, prefs); + + y += SECTION_GAP; + + FButton btnOk = new FButton(localizer.getMessage("lblOK")); + btnOk.setCommand((UiCommand) () -> setVisible(false)); + int btnX = (width - BUTTON_WIDTH) / 2; + add(btnOk, btnX, y, BUTTON_WIDTH, BUTTON_HEIGHT); + y += BUTTON_HEIGHT + PADDING; + + this.pack(); + this.setSize(width, y + 3 * PADDING); + } + + private int addDescription(int x, int y, int w, String text) { + String html = "
" + text + "
"; + FLabel desc = new FLabel.Builder().text(html) + .fontStyle(java.awt.Font.ITALIC).fontSize(13) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + int h = ROW_HEIGHT * 3 / 2; + add(desc, x, y, w, h); + return y + h + 2; + } + + private int addCheckbox(int x, int y, int w, String label, FPref pref, ForgePreferences prefs) { + FCheckBox cb = new FCheckBox(label, prefs.getPrefBoolean(pref)); + setBodyFont(cb); + cb.addActionListener(e -> { + boolean value = cb.isSelected(); + prefs.setPref(pref, value); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) { + controller.setYieldBoolPref(pref, value); + } + }); + add(cb, x, y, w, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private int addLabelWithDropdown(int x, int y, int w, String label, + FPref scopePref, Set options, ForgePreferences prefs) { + DeclineScope[] optArr = options.toArray(new DeclineScope[0]); + int lblWidth = w - DROPDOWN_WIDTH - PADDING; + Localizer loc = Localizer.getInstance(); + FLabel lbl = new FLabel.Builder().text(label).fontSize(BODY_FONT_SIZE) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + FComboBox combo = new FComboBox<>(); + setBodyFont(combo); + java.awt.Dimension dropSize = new java.awt.Dimension(DROPDOWN_WIDTH, ROW_HEIGHT); + combo.setPreferredSize(dropSize); + combo.setMinimumSize(dropSize); + combo.setMaximumSize(dropSize); + for (DeclineScope opt : optArr) { + combo.addItem(loc.getMessage(opt.labelKey())); + } + DeclineScope current = DeclineScope.fromPref(prefs.getPref(scopePref)); + for (int i = 0; i < optArr.length; i++) { + if (optArr[i] == current) { + combo.setSelectedIndex(i); + break; + } + } + combo.addActionListener(e -> { + int idx = combo.getSelectedIndex(); + if (idx >= 0 && idx < optArr.length) { + String value = optArr[idx].name(); + prefs.setPref(scopePref, value); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) controller.setYieldStringPref(scopePref, value); + } + }); + add(combo, x + w - DROPDOWN_WIDTH, y, DROPDOWN_WIDTH, ROW_HEIGHT); + + return y + ROW_HEIGHT; + } + + private int addTimeoutField(int x, int y, int w, ForgePreferences prefs) { + final Localizer loc = Localizer.getInstance(); + final int fieldWidth = DROPDOWN_WIDTH; + int lblWidth = w - fieldWidth - PADDING; + String tooltip = loc.getMessage("lblAutoPassBudgetDesc"); + FLabel lbl = new FLabel.Builder().text(loc.getMessage("lblAutoPassBudgetLabel")) + .fontSize(BODY_FONT_SIZE) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + lbl.setToolTipText(tooltip); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + int current = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + FTextField field = new FTextField.Builder() + .ghostText(loc.getMessage("lblDynamic")) + .tooltip(tooltip) + .text(current > 0 ? String.valueOf(current) : "") + .build(); + setBodyFont(field); + ((PlainDocument) field.getDocument()).setDocumentFilter(new DigitsOnlyFilter(4)); + field.getDocument().addDocumentListener(new DocumentListener() { + private void save() { + String text = field.getText(); + int value; + try { + value = (text == null || text.isEmpty()) ? 0 : Integer.parseInt(text); + } catch (NumberFormatException nfe) { value = 0; } + if (value > 9999) value = 9999; + String str = String.valueOf(value); + prefs.setPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) controller.setYieldStringPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); + } + @Override public void insertUpdate(DocumentEvent e) { save(); } + @Override public void removeUpdate(DocumentEvent e) { save(); } + @Override public void changedUpdate(DocumentEvent e) { save(); } + }); + add(field, x + w - fieldWidth, y, fieldWidth, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private static final class DigitsOnlyFilter extends DocumentFilter { + private final int maxLength; + DigitsOnlyFilter(int maxLength) { this.maxLength = maxLength; } + @Override + public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException { + if (string == null) return; + String filtered = scrub(string); + if (filtered.isEmpty()) return; + if (fb.getDocument().getLength() + filtered.length() > maxLength) return; + super.insertString(fb, offset, filtered, attr); + } + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException { + String filtered = text == null ? "" : scrub(text); + int newLength = fb.getDocument().getLength() - length + filtered.length(); + if (newLength > maxLength) return; + super.replace(fb, offset, length, filtered, attrs); + } + private static String scrub(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isDigit(c)) sb.append(c); + } + return sb.toString(); + } + } + + public void showDialog() { + setVisible(true); + dispose(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java new file mode 100644 index 00000000000..c09b47c238a --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java @@ -0,0 +1,96 @@ +/* + * Forge: Play Magic: the Gathering. + * Copyright (C) 2011 Forge Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package forge.screens.match.controllers; + +import java.awt.event.ActionListener; + +import javax.swing.JButton; + +import forge.gamemodes.match.YieldController; +import forge.gui.framework.ICDoc; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.CMatchUI; +import forge.screens.match.VYieldSettings; +import forge.screens.match.views.VYield; +import forge.util.Localizer; + +/** + * Controls the yield panel in the match UI. + * + *

(C at beginning of class name denotes a control class.) + */ +public class CYield implements ICDoc { + + private final CMatchUI matchUI; + private final VYield view; + + private final ActionListener actAutoPass = evt -> toggleAutoPass(); + private final ActionListener actSettings = evt -> openSettings(); + + public CYield(final CMatchUI matchUI) { + this.matchUI = matchUI; + this.view = new VYield(this); + } + + public final CMatchUI getMatchUI() { + return matchUI; + } + + public final VYield getView() { + return view; + } + + @Override + public void register() { + } + + @Override + public void initialize() { + initButton(view.getBtnAutoPass(), actAutoPass); + initButton(view.getBtnSettings(), actSettings); + + updateAutoPassButtonLabel(); + } + + private void initButton(final JButton button, final ActionListener onClick) { + button.removeActionListener(onClick); + button.addActionListener(onClick); + } + + @Override + public void update() { + updateAutoPassButtonLabel(); + } + + private void openSettings() { + new VYieldSettings(matchUI).showDialog(); + } + + public void toggleAutoPass() { + YieldController.toggleAutoPassNoActions(matchUI != null ? matchUI.getGameController() : null); + updateAutoPassButtonLabel(); + } + + private void updateAutoPassButtonLabel() { + boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + view.getBtnAutoPass().setToggled(!autoPassOn); + view.getBtnAutoPass().setText(Localizer.getInstance().getMessage( + autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass")); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 8c27de5eb71..24666a916f3 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -19,6 +19,7 @@ import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.screens.match.VAutoYields; +import forge.screens.match.VYieldSettings; import forge.screens.match.views.VField; import forge.screens.match.controllers.CDock.ArcState; import forge.toolbox.FSkin.SkinIcon; @@ -57,6 +58,7 @@ public JMenu getMenu() { menu.add(getMenuItem_TokensSeparateRow()); menu.add(getMenuItem_SeparateCombatStacks()); menu.add(getMenuItem_AutoYields()); + menu.add(getMenuItem_YieldSettings()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); return menu; @@ -189,6 +191,14 @@ private ActionListener getAutoYieldsAction() { }; } + private SkinnedMenuItem getMenuItem_YieldSettings() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedMenuItem menuItem = new SkinnedMenuItem(localizer.getMessage("lblYieldSettings")); + menuItem.setIcon((showIcons ? MenuUtil.getMenuIcon(FSkinProp.ICO_SETTINGS) : null)); + menuItem.addActionListener(e -> new VYieldSettings(matchUI).showDialog()); + return menuItem; + } + private SkinnedMenuItem getMenuItem_ViewDeckList() { final Localizer localizer = Localizer.getInstance(); final SkinnedMenuItem menuItem = new SkinnedMenuItem(localizer.getMessage("lblDeckList")); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index fa495026e6d..1d74656cdea 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -30,9 +30,13 @@ import javax.swing.SwingConstants; import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; +import forge.interfaces.IGameController; +import forge.screens.match.CMatchUI; import forge.gui.framework.IVDoc; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; @@ -74,8 +78,21 @@ public void setCardView(final CardView card) { private KeyAdapter buttonKeyAdapter = new KeyAdapter() { @Override public void keyPressed(final KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ESCAPE - && btnCancel.isEnabled() + if (e.getKeyCode() != KeyEvent.VK_ESCAPE) return; + + // sendYieldUpdate (not applyYieldUpdate) so the clear reaches the host's PCH proxy + CMatchUI matchUI = controller.getMatchUI(); + IGameController ctrl = matchUI == null ? null : matchUI.getGameController(); + PlayerView local = matchUI == null ? null : matchUI.getCurrentPlayer(); + if (ctrl != null && local != null && ctrl.getYieldController() != null + && ctrl.getYieldController().isYieldActive()) { + ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); + return; + } + + if (btnCancel.isEnabled() && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { btnCancel.doClick(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java new file mode 100644 index 00000000000..6d136543278 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java @@ -0,0 +1,101 @@ +/* + * Forge: Play Magic: the Gathering. + * Copyright (C) 2011 Forge Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package forge.screens.match.views; + +import javax.swing.JPanel; + +import forge.gui.framework.DragCell; +import forge.gui.framework.DragTab; +import forge.gui.framework.EDocID; +import forge.gui.framework.IVDoc; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.controllers.CYield; +import forge.toolbox.FButton; +import forge.toolbox.FSkin; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; + +/** + * Assembles Swing components of the yield controls panel. + * + *

(V at beginning of class name denotes a view class.) + */ +public class VYield implements IVDoc { + + private DragCell parentCell; + private final Localizer localizer = Localizer.getInstance(); + private final DragTab tab = new DragTab(localizer.getMessage("lblYieldOptions")); + + private final FButton btnAutoPass = new FButton(localizer.getMessage("lblYieldBtnAutoPass")); + private final FButton btnSettings = new FButton("..."); + + private final CYield controller; + + public VYield(final CYield controller) { + this.controller = controller; + + java.awt.Font smallFont = FSkin.getBoldFont(11).getBaseFont(); + btnAutoPass.setFont(smallFont); + btnSettings.setFont(smallFont); + + btnAutoPass.setToolTipText(localizer.getMessage("lblYieldBtnAutoPassTooltip")); + btnSettings.setToolTipText(localizer.getMessage("lblInterruptSettingsTooltip")); + } + + @Override + public void populate() { + JPanel container = parentCell.getBody(); + + boolean largerButtons = FModel.getPreferences().getPrefBoolean(FPref.UI_FOR_TOUCHSCREN); + String heightConstraint = largerButtons ? "h 40px:40px:60px" : "hmin 20px"; + + container.setLayout(new MigLayout("gap 1px!, insets 2px, fillx")); + + container.add(btnAutoPass, "growx, pushx, w 83%, " + heightConstraint + ", gaptop 2px"); + container.add(btnSettings, "w 17%, " + heightConstraint + ", gaptop 2px"); + } + + @Override + public void setParentCell(final DragCell cell0) { + this.parentCell = cell0; + } + + @Override + public DragCell getParentCell() { + return this.parentCell; + } + + @Override + public EDocID getDocumentID() { + return EDocID.REPORT_YIELD; + } + + @Override + public DragTab getTabLabel() { + return tab; + } + + @Override + public CYield getLayoutControl() { + return controller; + } + + public FButton getBtnAutoPass() { return btnAutoPass; } + public FButton getBtnSettings() { return btnSettings; } +} diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index f92530c42e7..c59e48c7be7 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -369,20 +369,26 @@ protected void drawOverlay(Graphics g) { } if (gameMenu != null) { - if (gameMenu.getChildCount() > 1) { + // Index trailing items from end — entries inserted before Settings don't shift them + int n = gameMenu.getChildCount(); + if (n > 1) { + int idxConcede = 0; + int idxAutoYields = 1; + int idxSettings = !Forge.isMobileAdventureMode ? n - 2 : -1; // Settings is second-from-last + int idxShowWinLose = !Forge.isMobileAdventureMode ? n - 1 : -1; // Show Win/Lose is last if (viewWinLose == null) { - gameMenu.getChildAt(0).setEnabled(!game.isMulligan()); - gameMenu.getChildAt(1).setEnabled(!game.isMulligan()); + gameMenu.getChildAt(idxConcede).setEnabled(!game.isMulligan()); + gameMenu.getChildAt(idxAutoYields).setEnabled(!game.isMulligan()); if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(2).setEnabled(!game.isMulligan()); - gameMenu.getChildAt(3).setEnabled(false); + gameMenu.getChildAt(idxSettings).setEnabled(!game.isMulligan()); + gameMenu.getChildAt(idxShowWinLose).setEnabled(false); } } else { - gameMenu.getChildAt(0).setEnabled(false); - gameMenu.getChildAt(1).setEnabled(false); + gameMenu.getChildAt(idxConcede).setEnabled(false); + gameMenu.getChildAt(idxAutoYields).setEnabled(false); if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(2).setEnabled(false); - gameMenu.getChildAt(3).setEnabled(true); + gameMenu.getChildAt(idxSettings).setEnabled(false); + gameMenu.getChildAt(idxShowWinLose).setEnabled(true); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index d0fb7c4470a..8cb1ce237b7 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -2,8 +2,11 @@ import forge.Forge; import forge.assets.FSkinImage; +import forge.gamemodes.match.YieldController; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.menu.FDropDownMenu; import forge.menu.FMenuItem; +import forge.model.FModel; import forge.screens.match.MatchController; import forge.screens.settings.SettingsScreen; import forge.toolbox.FEvent; @@ -62,6 +65,14 @@ public void setVisible(boolean b0) { autoYields.show(); } })); + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldOptions"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { + new VYieldOptions().show(); + })); + boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + String autoPassLabel = Forge.getLocalizer().getMessage(autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass"); + addItem(new FMenuItem(autoPassLabel, Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { + YieldController.toggleAutoPassNoActions(MatchController.instance.getGameController()); + })); if (!Forge.isMobileAdventureMode) { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblSettings"), Forge.hdbuttons ? FSkinImage.HDPREFERENCE : FSkinImage.SETTINGS, e -> { //pause game when spectating AI Match diff --git a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java new file mode 100644 index 00000000000..c530044ef16 --- /dev/null +++ b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java @@ -0,0 +1,293 @@ +package forge.screens.match.views; + +import com.badlogic.gdx.utils.Align; + +import forge.Forge; +import forge.assets.FSkinColor; +import forge.assets.FSkinColor.Colors; +import forge.assets.FSkinFont; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.MatchController; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FDialog; +import forge.toolbox.FLabel; +import forge.toolbox.FOptionPane; +import forge.toolbox.FScrollPane; +import forge.toolbox.FTextField; +import forge.util.TextBounds; +import forge.util.Utils; + +public class VYieldOptions extends FDialog { + + + private static final FSkinFont DESC_FONT = FSkinFont.get(12); + private static final FSkinFont BODY_FONT = FSkinFont.get(11); + private static final FSkinColor DESC_COLOR = FSkinColor.get(Colors.CLR_TEXT).alphaColor(0.55f); + + private final FScrollPane scroller; + + private final FLabel hdrInterrupts; + private final FLabel descInterrupts; + private final FCheckBox chkInterruptAttackers; + private final FCheckBox chkInterruptTargeting; + private final FCheckBox chkInterruptMassRemoval; + private final FCheckBox chkInterruptOpponentSpell; + private final FCheckBox chkInterruptTriggers; + private final FCheckBox chkInterruptReveal; + private final FCheckBox chkAutoPassRespectsInterrupts; + + private final FLabel hdrSuggestions; + private final FLabel descSuggestions; + private final FLabel lblStackScope; + private final FComboBox cboStackScope; + private final FLabel lblNoActionsScope; + private final FComboBox cboNoActionsScope; + + private final FCheckBox chkSuppressOwnTurn; + private final FCheckBox chkSuppressAfterYield; + + private final FLabel hdrSpeed; + private final FLabel lblBudget; + private final FLabel descBudget; + private final FTextField txtBudgetMs; + private final FCheckBox chkSkipPhaseDelay; + private final FCheckBox chkSkipResolveDelay; + + public VYieldOptions() { + super(Forge.getLocalizer().getMessage("lblYieldSettings"), 1); + final IGameController ctrl = MatchController.instance.getGameController(); + final ForgePreferences prefs = FModel.getPreferences(); + + scroller = add(new FScrollPane() { + @Override + protected ScrollBounds layoutAndGetScrollBounds(float visibleWidth, float visibleHeight) { + return layoutScrollerContent(visibleWidth); + } + }); + + hdrInterrupts = scroller.add(headerLabel("lblInterruptSettings")); + descInterrupts = scroller.add(descriptionLabel("lblInterruptSettingsDesc")); + chkInterruptAttackers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnAttackers"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS))); + chkInterruptTargeting = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTargeting"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING))); + chkInterruptMassRemoval = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnMassRemoval"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL))); + chkInterruptOpponentSpell = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnOpponentSpell"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL))); + chkInterruptTriggers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTriggers"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS))); + chkInterruptReveal = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnReveal"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL))); + chkAutoPassRespectsInterrupts = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblAutoPassRespectsInterrupts"), ctrl.getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS))); + + chkInterruptAttackers.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_ATTACKERS, chkInterruptAttackers.isSelected())); + chkInterruptTargeting.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_TARGETING, chkInterruptTargeting.isSelected())); + chkInterruptMassRemoval.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, chkInterruptMassRemoval.isSelected())); + chkInterruptOpponentSpell.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, chkInterruptOpponentSpell.isSelected())); + chkInterruptTriggers.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_TRIGGERS, chkInterruptTriggers.isSelected())); + chkInterruptReveal.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_REVEAL, chkInterruptReveal.isSelected())); + chkAutoPassRespectsInterrupts.setCommand(e -> persistBool(ctrl, FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, chkAutoPassRespectsInterrupts.isSelected())); + + hdrSuggestions = scroller.add(headerLabel("lblAutomaticSuggestions")); + descSuggestions = scroller.add(descriptionLabel("lblAutomaticSuggestionsDesc")); + lblStackScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestStackYield")) + .align(Align.left) + .build()); + cboStackScope = scroller.add(new FComboBox()); + DeclineScope[] stackScopes = SuggestionType.STACK_YIELD.allowedScopes().toArray(new DeclineScope[0]); + for (DeclineScope s : stackScopes) cboStackScope.addItem(Forge.getLocalizer().getMessage(s.labelKey())); + cboStackScope.setSelectedIndex(scopeIndex(stackScopes, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD))); + cboStackScope.setDropDownChangeHandler(e -> persistScope(ctrl, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, stackScopes, cboStackScope.getSelectedIndex())); + + lblNoActionsScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestNoActions")) + .align(Align.left) + .build()); + cboNoActionsScope = scroller.add(new FComboBox()); + DeclineScope[] noActionsScopes = SuggestionType.NO_ACTIONS.allowedScopes().toArray(new DeclineScope[0]); + for (DeclineScope s : noActionsScopes) cboNoActionsScope.addItem(Forge.getLocalizer().getMessage(s.labelKey())); + cboNoActionsScope.setSelectedIndex(scopeIndex(noActionsScopes, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS))); + cboNoActionsScope.setDropDownChangeHandler(e -> persistScope(ctrl, FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, noActionsScopes, cboNoActionsScope.getSelectedIndex())); + + chkSuppressOwnTurn = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressOnOwnTurn"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_ON_OWN_TURN))); + chkSuppressAfterYield = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressAfterYield"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_AFTER_END))); + chkSuppressOwnTurn.setCommand(e -> persistBool(ctrl, FPref.YIELD_SUPPRESS_ON_OWN_TURN, chkSuppressOwnTurn.isSelected())); + chkSuppressAfterYield.setCommand(e -> persistBool(ctrl, FPref.YIELD_SUPPRESS_AFTER_END, chkSuppressAfterYield.isSelected())); + + hdrSpeed = scroller.add(headerLabel("lblSpeedSettings")); + lblBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetLabel")) + .align(Align.left) + .build()); + descBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetDesc")) + .font(DESC_FONT) + .textColor(DESC_COLOR) + .align(Align.left) + .build()); + int currentBudget = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + txtBudgetMs = scroller.add(new FTextField(currentBudget > 0 ? String.valueOf(currentBudget) : "")); + txtBudgetMs.setGhostText(Forge.getLocalizer().getMessage("lblDynamic")); + txtBudgetMs.setIsNumeric(true); + + chkSkipPhaseDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipPhaseDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_PHASE_DELAY))); + chkSkipResolveDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipResolveDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_RESOLVE_DELAY))); + chkSkipPhaseDelay.setCommand(e -> persistBool(ctrl, FPref.YIELD_SKIP_PHASE_DELAY, chkSkipPhaseDelay.isSelected())); + chkSkipResolveDelay.setCommand(e -> persistBool(ctrl, FPref.YIELD_SKIP_RESOLVE_DELAY, chkSkipResolveDelay.isSelected())); + + FLabel[] bodyLabels = { + chkInterruptAttackers, chkInterruptTargeting, chkInterruptMassRemoval, + chkInterruptOpponentSpell, chkInterruptTriggers, chkInterruptReveal, + chkAutoPassRespectsInterrupts, + lblStackScope, lblNoActionsScope, + chkSuppressOwnTurn, chkSuppressAfterYield, + lblBudget, + chkSkipPhaseDelay, chkSkipResolveDelay + }; + for (FLabel l : bodyLabels) l.setFont(BODY_FONT); + cboStackScope.setFont(BODY_FONT); + cboNoActionsScope.setFont(BODY_FONT); + txtBudgetMs.setFont(BODY_FONT); + + initButton(0, Forge.getLocalizer().getMessage("lblOK"), e -> { + String txt = txtBudgetMs.getText().trim(); + if (txt.isEmpty()) { + txt = "0"; + } + try { + int v = Integer.parseInt(txt); + if (v < 0) v = 0; + persistString(ctrl, FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, String.valueOf(v)); + } catch (NumberFormatException nfe) { + FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblInvalidBudget")); + return; + } + hide(); + }); + } + + private static FLabel headerLabel(String localizerKey) { + return new FLabel.ButtonBuilder() + .text(Forge.getLocalizer().getMessage(localizerKey)) + .font(FSkinFont.get(14)) + .align(Align.center) + .build(); + } + + private static FLabel descriptionLabel(String localizerKey) { + return new FLabel.Builder() + .text(Forge.getLocalizer().getMessage(localizerKey)) + .font(DESC_FONT) + .textColor(DESC_COLOR) + .align(Align.left) + .build(); + } + + private FScrollPane.ScrollBounds layoutScrollerContent(float visibleWidth) { + float padding = FOptionPane.PADDING; + float rowGap = Utils.scale(6); + float sectionGap = Utils.scale(14); + + float x = padding; + float w = visibleWidth - 2 * padding; + float y = padding; + + TextBounds rowBounds = chkInterruptAttackers.getAutoSizeBounds(); + float rowH = Math.max(rowBounds.height, Utils.scale(28)); + float headerH = Math.round(Utils.AVG_FINGER_HEIGHT * 0.6f); + + hdrInterrupts.setBounds(x, y, w, headerH); + y += headerH; + float descH = DESC_FONT.getCapHeight() * 2.2f; + descInterrupts.setBounds(x, y, w, descH); + y += descH + Utils.scale(2); + FCheckBox[] interrupts = { + chkInterruptAttackers, chkInterruptTargeting, chkInterruptMassRemoval, + chkInterruptOpponentSpell, chkInterruptTriggers, chkInterruptReveal, + chkAutoPassRespectsInterrupts + }; + for (FCheckBox cb : interrupts) { + cb.setBounds(x, y, w, rowH); + y += rowH + rowGap; + } + y += sectionGap - rowGap; + + hdrSuggestions.setBounds(x, y, w, headerH); + y += headerH; + descSuggestions.setBounds(x, y, w, descH); + y += descH + Utils.scale(2); + + float dropdownW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float scopeLabelW = w - dropdownW - padding; + float scopeRowH = Math.max(rowH, FTextField.getDefaultHeight()); + + lblStackScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboStackScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + lblNoActionsScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboNoActionsScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + chkSuppressOwnTurn.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSuppressAfterYield.setBounds(x, y, w, rowH); + y += rowH + sectionGap; + + hdrSpeed.setBounds(x, y, w, headerH); + y += headerH; + + float fieldH = FTextField.getDefaultHeight(); + float fieldW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float lblW = w - fieldW - padding; + lblBudget.setBounds(x, y, lblW, fieldH); + txtBudgetMs.setBounds(x + w - fieldW, y, fieldW, fieldH); + y += fieldH + Utils.scale(2); + descBudget.setBounds(x, y, w, DESC_FONT.getCapHeight() * 2.2f); + y += DESC_FONT.getCapHeight() * 2.2f + rowGap; + + chkSkipPhaseDelay.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSkipResolveDelay.setBounds(x, y, w, rowH); + y += rowH + padding; + + return new FScrollPane.ScrollBounds(visibleWidth, y); + } + + private static int scopeIndex(DeclineScope[] options, String prefValue) { + DeclineScope current = DeclineScope.fromPref(prefValue); + for (int i = 0; i < options.length; i++) { + if (options[i] == current) return i; + } + return 0; + } + + private static void persistBool(IGameController ctrl, FPref pref, boolean value) { + FModel.getPreferences().setPref(pref, value); + FModel.getPreferences().save(); + if (ctrl != null) ctrl.setYieldBoolPref(pref, value); + } + + private static void persistScope(IGameController ctrl, FPref pref, DeclineScope[] options, int index) { + if (index < 0 || index >= options.length) return; + String value = options[index].name(); + FModel.getPreferences().setPref(pref, value); + FModel.getPreferences().save(); + if (ctrl != null) ctrl.setYieldStringPref(pref, value); + } + + private static void persistString(IGameController ctrl, FPref pref, String value) { + FModel.getPreferences().setPref(pref, value); + FModel.getPreferences().save(); + if (ctrl != null) ctrl.setYieldStringPref(pref, value); + } + + @Override + protected float layoutAndGetHeight(float width, float maxHeight) { + scroller.setBounds(0, 0, width, maxHeight); + scroller.scrollToTop(); + return maxHeight; + } +} diff --git a/forge-gui/res/defaults/match.xml b/forge-gui/res/defaults/match.xml index 839733ea2a8..99ef999756c 100644 --- a/forge-gui/res/defaults/match.xml +++ b/forge-gui/res/defaults/match.xml @@ -8,6 +8,7 @@ REPORT_MESSAGE + REPORT_YIELD DEV_MODE CARD_ANTES BUTTON_DOCK diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 2aee7e96d68..30163f69ecc 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1626,6 +1626,45 @@ lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield lblYieldingUntilPhaseFmt=Yielding until {0}''s {1}.\nYou may cancel this yield to take an action. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. lblYieldToEntireStack=Yield to entire stack +lblYieldOptions=Yield Options +lblYieldSettings=Yield Settings +lblYieldBtnAutoPass=Auto-Pass: OFF +lblYieldBtnAutoPassOn=Auto-Pass: ON +lblYieldBtnAutoPassTooltip=Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare. +lblInterruptSettings=Yield Interrupt Settings +lblInterruptSettingsDesc=Cancel an active yield and return priority to you when these game events occur. +lblInterruptSettingsTooltip=Configure interrupt conditions and automatic suggestions +lblInterruptOnAttackers=When attackers declared against you +lblInterruptOnTargeting=When targeted by spell or ability +lblInterruptOnOpponentSpell=When opponent casts a spell or activates an ability +lblInterruptOnReveal=When cards revealed or choices made +lblInterruptOnMassRemoval=When mass removal spell cast +lblInterruptOnTriggers=When triggered abilities on stack +lblAutoPassRespectsInterrupts=Auto-pass respects interrupts +lblAutoPassBudgetLabel=Auto-pass calculation timeout (ms) +lblAutoPassBudgetDesc=Dynamic = 50ms × playable cards (50-1500ms) +lblDynamic=Dynamic +lblInvalidBudget=Budget must be a non-negative integer. +lblAutomaticSuggestions=Automatic Yield Suggestions +lblAutomaticSuggestionsDesc=Prompt you to start a yield when no useful actions are available. +lblSuggestStackYield=When can''t respond to stack +lblSuggestNoActions=When no actions available +lblSuppressAfterYield=Suppress immediately after yield ends +lblSuppressOnOwnTurn=Suppress on own turn +lblDeclScopeNever=Never +lblDeclScopeAlways=Always +lblDeclScopeStack=Once per stack +lblDeclScopeTurn=Once per turn +lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? +lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? +lblYieldSuggestionDeclineHint=(Declining disables this prompt until next turn.) +lblYieldSuggestionDeclineHintStack=(Declining disables this prompt until the stack clears.) +lblSpeedSettings=Speed Settings +lblSkipPhaseDelay=Skip delay between phases +lblSkipResolveDelay=Skip delay when stack resolves +lblSHORTCUT_YIELD_OPTIONS=Yield: Toggle Yield Options +lblSHORTCUT_YIELD_AUTO_PASS=Yield: Toggle Auto-Pass +lblSHORTCUT_YIELD_CANCEL=Yield: Cancel Active Yield lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index 2bc7f4fb5df..c7b02262e11 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -626,6 +626,7 @@ public void applyYieldUpdate(YieldUpdate update) { PlayerView pv; if (update instanceof YieldUpdate.ClearMarker u) pv = u.player(); else if (update instanceof YieldUpdate.StackYield u) pv = u.player(); + else if (update instanceof YieldUpdate.SetAutoPassUntilEndOfTurn u) pv = u.player(); else return; IGameController c = getGameController(pv); if (c != null) c.applyYieldUpdate(update); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java new file mode 100644 index 00000000000..1f030295efa --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java @@ -0,0 +1,24 @@ +package forge.gamemodes.match; + +/** How long a smart-suggestion decline suppresses the suggestion. Persisted via {@link Enum#name()}. */ +public enum DeclineScope { + NEVER("lblDeclScopeNever"), + ALWAYS("lblDeclScopeAlways"), + STACK("lblDeclScopeStack"), + TURN("lblDeclScopeTurn"); + + private final String labelKey; + + DeclineScope(String labelKey) { + this.labelKey = labelKey; + } + + /** Localizer key for the dropdown label. */ + public String labelKey() { return labelKey; } + + /** Parse stored FPref value. Unknown / null → {@link #NEVER}. */ + public static DeclineScope fromPref(String s) { + if (s == null || s.isEmpty()) return NEVER; + try { return valueOf(s); } catch (IllegalArgumentException ignored) { return NEVER; } + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 1f1b198a92b..f619f554cbd 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -388,6 +388,7 @@ public void endCurrentGame() { } humanController.getGui().setGameSpeed(PlaybackSpeed.NORMAL); humanController.getYieldController().clearAutoYields(); + humanController.getYieldController().resetForNewGame(); if (humanCount > 0) //conceded humanController.getGui().afterGameEnd(); @@ -425,10 +426,6 @@ private final class MatchUiEventVisitor extends IGameEventVisitor.Base imp public Void visit(final UiEventBlockerAssigned event) { for (final PlayerControllerHuman humanController : humanControllers) { humanController.getGui().updateSingleCard(event.blocker()); - final PlayerView p = humanController.getPlayer().getView(); - if (event.attackerBeingBlocked() != null && event.attackerBeingBlocked().getController().equals(p)) { - humanController.autoPassCancel(); - } } return null; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java b/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java new file mode 100644 index 00000000000..e714a7a341f --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java @@ -0,0 +1,29 @@ +package forge.gamemodes.match; + +import forge.localinstance.properties.ForgePreferences.FPref; + +import java.util.EnumSet; +import java.util.Set; + +import static forge.gamemodes.match.DeclineScope.ALWAYS; +import static forge.gamemodes.match.DeclineScope.NEVER; +import static forge.gamemodes.match.DeclineScope.STACK; +import static forge.gamemodes.match.DeclineScope.TURN; + +/** A user-facing yield suggestion. Owns the metadata that constrains its UX: + * which decline scopes apply, and which FPref persists the chosen scope. */ +public enum SuggestionType { + STACK_YIELD(EnumSet.of(NEVER, ALWAYS, STACK, TURN), FPref.YIELD_DECLINE_SCOPE_STACK_YIELD), + NO_ACTIONS (EnumSet.of(NEVER, ALWAYS, TURN), FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + + private final Set allowedScopes; + private final FPref scopePref; + + SuggestionType(Set allowedScopes, FPref scopePref) { + this.allowedScopes = allowedScopes; + this.scopePref = scopePref; + } + + public Set allowedScopes() { return allowedScopes; } + public FPref scopePref() { return scopePref; } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 9cc26533bb8..5b2b91d488d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -1,16 +1,29 @@ package forge.gamemodes.match; import forge.game.GameView; +import forge.game.ability.ApiType; +import forge.game.card.CardView; +import forge.game.combat.CombatView; import forge.game.phase.PhaseType; +import forge.game.player.Player; import forge.game.player.PlayerView; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.SpellAbilityStackInstance; +import forge.game.spellability.StackItemView; +import forge.gui.interfaces.IGuiGame; +import forge.interfaces.IGameController; import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.AutoYieldStore; import forge.player.LobbyPlayerHuman; import forge.player.PersistentYieldStore; import forge.player.PlayerControllerHuman; +import forge.util.collect.FCollection; +import forge.util.collect.FCollectionView; +import java.util.EnumMap; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -45,6 +58,10 @@ public class YieldController { private final AutoYieldStore localStore = new AutoYieldStore(); private final Map> skipPhases = new HashMap<>(); + /** Override wins, FModel is fallback. Synced per-PCH so each client's prefs govern their proxy. */ + private final EnumMap boolPrefOverrides = new EnumMap<>(FPref.class); + private final EnumMap stringPrefOverrides = new EnumMap<>(FPref.class); + public YieldController(PlayerControllerHuman owner) { this.owner = owner; } @@ -70,17 +87,54 @@ public YieldMarker getAutoPassUntilMarker() { return autoPassUntilMarker; } - public void setAutoPassUntilStackEmpty(boolean active) { - if (active) autoPassUntilEOT = false; + // All mutators synchronized — fields touched from EDT, Netty, game thread. + // Activating any yield type clears the others — only one yield type may be active at a time (APINA is orthogonal). + public synchronized void setAutoPassUntilStackEmpty(boolean active) { + if (active) { + autoPassUntilEOT = false; + clearMarker(); + } this.autoPassUntilStackEmpty = active; } - public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { + public synchronized void setAutoPassUntilEndOfTurn(boolean active) { + if (active) { + autoPassUntilStackEmpty = false; + clearMarker(); + } this.autoPassUntilEOT = active; } + public synchronized boolean getBoolPref(FPref pref) { + Boolean override = boolPrefOverrides.get(pref); + return override != null ? override : FModel.getPreferences().getPrefBoolean(pref); + } + public synchronized void setBoolPref(FPref pref, boolean value) { + boolPrefOverrides.put(pref, value); + } + + public synchronized String getStringPref(FPref pref) { + String override = stringPrefOverrides.get(pref); + return override != null ? override : FModel.getPreferences().getPref(pref); + } + public synchronized void setStringPref(FPref pref, String value) { + stringPrefOverrides.put(pref, value); + } + + public DeclineScope getDeclineScope(FPref pref) { + return DeclineScope.fromPref(getStringPref(pref)); + } + + public Map snapshotBoolPrefs() { + return new EnumMap<>(boolPrefOverrides); + } + public Map snapshotStringPrefs() { + return new EnumMap<>(stringPrefOverrides); + } + // setMarker/clearMarker are mutated from EDT (right-click), Netty (wire receive), and game thread. public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; + autoPassUntilStackEmpty = false; if (phaseOwner == null || phase == null) { clearMarker(); return; @@ -206,6 +260,20 @@ public void clearAutoYields() { activeStore().onGameEnd(matchOver); } + /** Clear all transient yield state so it doesn't carry into the next game of the match. */ + public synchronized void resetForNewGame() { + autoPassUntilEOT = false; + autoPassUntilStackEmpty = false; + autoPassUntilMarker = null; + hasLeftMarker = false; + activationOnMarker = false; + declinedSuggestionTurn.clear(); + lastSeenStackNonEmpty = false; + wasAutoPassingLastTick = false; + yieldJustEndedFlag = false; + // boolPrefOverrides / stringPrefOverrides intentionally kept — per-match, not per-game + } + public boolean getDisableAutoYields() { return activeStore().isDisabled(); } @@ -245,7 +313,8 @@ public YieldStateSnapshot buildClientSnapshot(Map } // Trigger decisions are per-game; deltas flow during play. Map triggers = new HashMap<>(); - return new YieldStateSnapshot(cardYields, abilityYields, triggers, getDisableAutoYields(), skipPhases); + return new YieldStateSnapshot(cardYields, abilityYields, triggers, getDisableAutoYields(), skipPhases, + snapshotBoolPrefs(), snapshotStringPrefs()); } /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ @@ -259,5 +328,294 @@ public void applyClientSeed(YieldStateSnapshot snap) { localStore.setDisabled(snap.autoYieldsDisabled()); skipPhases.clear(); skipPhases.putAll(snap.skipPhases()); + boolPrefOverrides.clear(); + if (snap.boolPrefOverrides() != null) boolPrefOverrides.putAll(snap.boolPrefOverrides()); + stringPrefOverrides.clear(); + if (snap.stringPrefOverrides() != null) stringPrefOverrides.putAll(snap.stringPrefOverrides()); + } + + /** + * Apply a wire envelope variant to local state. Returns true if a yield was + * activated (caller may want to refresh the prompt UI). Per-method locking is + * provided by the delegated setters. Unhandled variants no-op — wire routing + * is responsible for ensuring only valid variants reach each side. + */ + public boolean apply(YieldUpdate update) { + if (update instanceof YieldUpdate.SetMarker u) { + setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); + return true; + } else if (update instanceof YieldUpdate.ClearMarker) { + clearMarker(); + } else if (update instanceof YieldUpdate.StackYield u) { + setAutoPassUntilStackEmpty(u.active()); + return u.active(); + } else if (update instanceof YieldUpdate.SetAutoPassUntilEndOfTurn u) { + setAutoPassUntilEndOfTurn(u.active()); + return u.active(); + } else if (update instanceof YieldUpdate.TriggerDecision u) { + setTriggerDecision(u.trigId(), u.decision()); + } else if (update instanceof YieldUpdate.CardAutoYield u) { + applyAutoYieldFromWire(u.cardKey(), u.active()); + } else if (update instanceof YieldUpdate.SkipPhase u) { + setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); + } else if (update instanceof YieldUpdate.SetYieldBoolPref u) { + setBoolPref(u.pref(), u.value()); + } else if (update instanceof YieldUpdate.SetYieldStringPref u) { + setStringPref(u.pref(), u.value()); + } else if (update instanceof YieldUpdate.SeedFromClient u) { + applyClientSeed(u.snapshot()); + } + return false; + } + + public boolean isYieldActive() { + return autoPassUntilEOT || autoPassUntilStackEmpty || autoPassUntilMarker != null; + } + + /** EOT and marker yields can be interrupted. Stack-yield is fire and forget — only stack-empty turns it off. */ + public boolean isInterruptibleYieldActive() { + return autoPassUntilEOT || autoPassUntilMarker != null; + } + + public synchronized void clearActiveYieldAndDispatch() { + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + IGuiGame gui = owner.getGui(); + boolean anyCleared = false; + if (autoPassUntilMarker != null) { + clearMarker(); + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.ClearMarker(local)); + anyCleared = true; + } + if (autoPassUntilStackEmpty) { + autoPassUntilStackEmpty = false; + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.StackYield(local, false)); + anyCleared = true; + } + if (autoPassUntilEOT) { + autoPassUntilEOT = false; + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); + anyCleared = true; + } + if (anyCleared && gui != null) gui.updateAutoPassPrompt(); + } + + /** Toggle APINA: flip pref, persist, push to controller. Returns new value. */ + public static boolean toggleAutoPassNoActions(IGameController ctrl) { + if (ctrl == null) return false; + ForgePreferences prefs = FModel.getPreferences(); + boolean newVal = !prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + prefs.setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + prefs.save(); + ctrl.setYieldBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + return newVal; + } + + /** Eager check at REVEAL/notifyOfValue call sites — no GameEventReveal exists. */ + public void maybeInterruptOnReveal() { + if (isInterruptibleYieldActive() && getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) { + clearActiveYieldAndDispatch(); + } + } + + public void onSpellAbilityCast(SpellAbilityStackInstance si, GameView gameView) { + if (!isInterruptibleYieldActive()) return; + if (si == null) return; + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + Player activator = si.getActivatingPlayer(); + boolean isOpponent = activator != null && !activator.getView().equals(local); + + if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + clearActiveYieldAndDispatch(); + return; + } + StackItemView siv = StackItemView.get(si); + if (siv != null && getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING) + && targetsPlayerOrPermanents(siv, local)) { + clearActiveYieldAndDispatch(); + return; + } + if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) + && isMassRemovalInstance(si)) { + clearActiveYieldAndDispatch(); + return; + } + if (si.isTrigger() && getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS)) { + clearActiveYieldAndDispatch(); + } + } + + public void onAttackersDeclared(CombatView combat) { + if (!isInterruptibleYieldActive()) return; + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS) && isBeingAttacked(combat, local)) { + clearActiveYieldAndDispatch(); + } + } + + private final EnumMap declinedSuggestionTurn = new EnumMap<>(SuggestionType.class); + private boolean lastSeenStackNonEmpty = false; + private boolean wasAutoPassingLastTick = false; + private boolean yieldJustEndedFlag = false; + + public synchronized void onPriorityReceived(boolean stackNonEmpty) { + if (lastSeenStackNonEmpty && !stackNonEmpty) { + if (getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) == DeclineScope.STACK) { + declinedSuggestionTurn.remove(SuggestionType.STACK_YIELD); + } + } + lastSeenStackNonEmpty = stackNonEmpty; + } + + public synchronized void declineSuggestion(SuggestionType type) { + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv == null) return; + declinedSuggestionTurn.put(type, gv.getTurn()); + } + + /** + * NEVER disables the suggestion entirely; ALWAYS never suppresses; + * STACK/TURN suppress if declined this turn (STACK also self-clears on stack-empty + * transition via {@link #onPriorityReceived}). + */ + public synchronized boolean isSuggestionDeclined(SuggestionType type) { + DeclineScope scope = getDeclineScope(type.scopePref()); + if (scope == DeclineScope.NEVER) return true; + if (scope == DeclineScope.ALWAYS) return false; + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv == null) return false; + Integer turnDeclined = declinedSuggestionTurn.get(type); + return turnDeclined != null && turnDeclined == gv.getTurn(); + } + + public synchronized void noteMayAutoPassResult(boolean nowMayAutoPass) { + if (wasAutoPassingLastTick && !nowMayAutoPass) yieldJustEndedFlag = true; + wasAutoPassingLastTick = nowMayAutoPass; + } + + /** Self-clearing read of the mayAutoPass true→false edge. */ + public synchronized boolean didYieldJustEnd() { + boolean f = yieldJustEndedFlag; + yieldJustEndedFlag = false; + return f; + } + + /** Per-tick predicate (distinct from one-shot yields). Used by mayAutoPass. */ + public boolean isAutoPassingNoActions(PlayerView player) { + if (!getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return false; + if (getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS) && shouldInterruptYield(player)) { + return false; + } + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv != null && gv.getStack() != null && gv.getStack().isEmpty()) { + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType phase = gv.getPhase(); + if (turnPlayer != null && phase != null + && owner.getGui().isUiSetToSkipPhase(turnPlayer, phase)) { + return true; + } + } + return player != null && !player.hasAvailableActions(); + } + + /** State-scanner for APINA's RESPECTS_INTERRUPTS only — event-driven flow uses classifiers directly. */ + public boolean shouldInterruptYield(PlayerView player) { + if (player == null) return false; + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv == null) return false; + PhaseType phase = gv.getPhase(); + CombatView combat = gv.getCombat(); + + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS) + && phase == PhaseType.COMBAT_DECLARE_ATTACKERS + && combat != null && isBeingAttacked(combat, player)) { + return true; + } + FCollectionView stack = gv.getStack(); + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING) && stack != null) { + for (StackItemView si : stack) { + if (targetsPlayerOrPermanents(si, player)) return true; + } + } + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + StackItemView top = gv.peekStack(); + if (top != null) { + PlayerView act = top.getActivatingPlayer(); + if (act != null && !act.equals(player)) return true; + } + } + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) && hasMassRemovalOnStack(gv, player)) { + return true; + } + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS) && stack != null) { + for (StackItemView si : stack) { + if (si.isTrigger()) return true; + } + } + return false; + } + + /** Host-only: walks live engine stack via gameView.getGame(). Opponent spells only. */ + private static boolean hasMassRemovalOnStack(GameView gameView, PlayerView player) { + forge.game.Game game = gameView.getGame(); + if (game == null) return false; // host-only path; defensive on the client + for (SpellAbilityStackInstance si : game.getStack()) { + Player activator = si.getActivatingPlayer(); + if (activator == null || activator.getView().equals(player)) continue; + if (isMassRemovalInstance(si)) return true; + } + return false; + } + + private static boolean isBeingAttacked(CombatView combatView, PlayerView player) { + if (combatView == null) return false; + FCollection attackersOfPlayer = combatView.getAttackersOf(player); + if (attackersOfPlayer != null && !attackersOfPlayer.isEmpty()) return true; + for (forge.game.GameEntityView defender : combatView.getDefenders()) { + if (defender instanceof CardView cardDefender) { + PlayerView controller = cardDefender.getController(); + if (controller != null && controller.equals(player)) { + FCollection attackers = combatView.getAttackersOf(defender); + if (attackers != null && !attackers.isEmpty()) return true; + } + } + } + return false; + } + + /** Recurses into sub-instances (e.g. Oona, where targeting is in a sub-ability). */ + private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView player) { + FCollectionView targetPlayers = si.getTargetPlayers(); + if (targetPlayers != null) { + for (PlayerView target : targetPlayers) { + if (target.equals(player)) return true; + } + } + FCollectionView targetCards = si.getTargetCards(); + if (targetCards != null) { + for (CardView target : targetCards) { + if (target.getController() != null && target.getController().equals(player)) return true; + } + } + StackItemView subInstance = si.getSubInstance(); + if (subInstance != null && targetsPlayerOrPermanents(subInstance, player)) return true; + return false; + } + + /** Recurses into sub-instances for modal spells like Farewell. */ + private static boolean isMassRemovalInstance(SpellAbilityStackInstance si) { + SpellAbility sa = si.getSpellAbility(); + if (sa != null && isMassRemovalApi(sa.getApi())) return true; + SpellAbilityStackInstance subInstance = si.getSubInstance(); + return subInstance != null && isMassRemovalInstance(subInstance); + } + + private static boolean isMassRemovalApi(ApiType api) { + return api == ApiType.DestroyAll + || api == ApiType.DamageAll + || api == ApiType.SacrificeAll + || api == ApiType.ChangeZoneAll; } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java index 0e4ef02e470..a4cd8f56c1a 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java @@ -2,6 +2,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import java.io.Serializable; @@ -19,5 +20,7 @@ public record YieldStateSnapshot( Set abilityYields, Map triggerDecisions, boolean autoYieldsDisabled, - Map> skipPhases + Map> skipPhases, + Map boolPrefOverrides, + Map stringPrefOverrides ) implements Serializable {} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index 09096e6cdcc..3963d46382e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -2,6 +2,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import java.io.Serializable; @@ -16,9 +17,12 @@ public sealed interface YieldUpdate extends Serializable permits YieldUpdate.SetMarker, YieldUpdate.ClearMarker, YieldUpdate.StackYield, + YieldUpdate.SetAutoPassUntilEndOfTurn, YieldUpdate.TriggerDecision, YieldUpdate.CardAutoYield, YieldUpdate.SkipPhase, + YieldUpdate.SetYieldBoolPref, + YieldUpdate.SetYieldStringPref, YieldUpdate.SeedFromClient { /** {@code atOrPastAtClick}: priority was at-or-past target on owner's turn when the user clicked — computed by the UI so client cache and host PCH initialize identically. */ @@ -28,11 +32,17 @@ record ClearMarker(PlayerView player) implements YieldUpdate {} record StackYield(PlayerView player, boolean active) implements YieldUpdate {} + record SetAutoPassUntilEndOfTurn(PlayerView player, boolean active) implements YieldUpdate {} + record TriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) implements YieldUpdate {} record CardAutoYield(String cardKey, boolean active, boolean abilityScope) implements YieldUpdate {} record SkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} + record SetYieldBoolPref(FPref pref, boolean value) implements YieldUpdate {} + + record SetYieldStringPref(FPref pref, String value) implements YieldUpdate {} + record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java index 2b02c874848..65e0ae93ef5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java @@ -4,8 +4,6 @@ import forge.game.player.Player; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbility; -import forge.gamemodes.match.YieldController; -import forge.gamemodes.match.YieldUpdate; import forge.gui.FThreads; import forge.player.PlayerControllerHuman; import forge.util.ITriggerEvent; @@ -93,22 +91,7 @@ public void selectButtonOK() { } @Override public void selectButtonCancel() { - // autoPassCancel first: its mayAutoPass gate needs at least one mode still active to do UI updates. - controller.autoPassCancel(); - YieldController yc = controller.getYieldController(); - PlayerView pv = controller.getLocalPlayerView(); - if (yc.getAutoPassUntilMarker() != null) { - yc.clearMarker(); - if (controller.getGui() != null) { - controller.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(pv)); - } - } - if (yc.autoPassUntilStackEmpty()) { - yc.setAutoPassUntilStackEmpty(false); - if (controller.getGui() != null) { - controller.getGui().applyYieldUpdate(new YieldUpdate.StackYield(pv, false)); - } - } + controller.getYieldController().clearActiveYieldAndDispatch(); } @Override diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java index 8de133bbe16..fe4010c0b45 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java @@ -18,13 +18,22 @@ package forge.gamemodes.match.input; import forge.game.Game; +import forge.game.GameView; import forge.game.card.Card; +import forge.game.phase.PhaseType; import forge.game.player.Player; +import forge.game.player.PlayerView; import forge.game.player.actions.PassPriorityAction; import forge.game.spellability.SpellAbility; +import forge.game.spellability.StackItemView; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.server.FServerManager; import forge.gamemodes.net.server.FServerManager.AfkTimeout; import forge.localinstance.properties.ForgePreferences.FPref; +import forge.util.collect.FCollectionView; import forge.model.FModel; import forge.player.GamePlayerUtil; import forge.player.PlayerControllerHuman; @@ -49,6 +58,9 @@ public class InputPassPriority extends InputSyncronizedBase { private List chosenSa; + private SuggestionType pendingSuggestion = null; + private String pendingSuggestionMessage = null; + public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } @@ -69,6 +81,83 @@ public void showAndWait() { /** {@inheritDoc} */ @Override public final void showMessage() { + if (!isAlreadyYielding()) { + // Suppress one prompt after a yield ends — avoids "yielded → ended → yield again?" loop + if (getController().getYieldController().getBoolPref(FPref.YIELD_SUPPRESS_AFTER_END) + && getController().getYieldController().didYieldJustEnd()) { + showNormalPrompt(); + return; + } + + if (getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + showNormalPrompt(); + return; + } + + // Both scopes NEVER → skip including stack-transition tracking (no decline state to maintain) + DeclineScope stackScope = getController().getYieldController().getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD); + DeclineScope noActionsScope = getController().getYieldController().getDeclineScope(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + if (stackScope == DeclineScope.NEVER && noActionsScope == DeclineScope.NEVER) { + showNormalPrompt(); + return; + } + + GameView gvForStack = getGameView(); + boolean stackNonEmpty = gvForStack != null && gvForStack.getStack() != null + && !gvForStack.getStack().isEmpty(); + getController().getYieldController().onPriorityReceived(stackNonEmpty); + + Localizer loc = Localizer.getInstance(); + + if (!getController().getYieldController().isSuggestionDeclined(SuggestionType.STACK_YIELD) + && shouldShowStackYieldPrompt()) { + pendingSuggestion = SuggestionType.STACK_YIELD; + pendingSuggestionMessage = loc.getMessage("lblCannotRespondToStackYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + if (!getController().getYieldController().isSuggestionDeclined(SuggestionType.NO_ACTIONS) + && shouldShowNoActionsPrompt()) { + pendingSuggestion = SuggestionType.NO_ACTIONS; + pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + } + + showNormalPrompt(); + } + + private void showYieldSuggestionPrompt() { + // State may have flipped between the initial check and now (e.g. async multiplayer click). + if (isAlreadyYielding()) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + + Localizer loc = Localizer.getInstance(); + String fullMessage = pendingSuggestionMessage; + DeclineScope scope = getController().getYieldController().getDeclineScope(pendingSuggestion.scopePref()); + if (scope == DeclineScope.STACK) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHintStack"); + } else if (scope == DeclineScope.TURN) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHint"); + } + showMessage(fullMessage); + chosenSa = null; + getController().getGui().updateButtons(getOwner(), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline"), + true, true, true); + getController().getGui().alertUser(); + } + + private void showNormalPrompt() { + pendingSuggestion = null; + pendingSuggestionMessage = null; + showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); @@ -82,9 +171,102 @@ public final void showMessage() { getController().getGui().alertUser(); } + private boolean isAlreadyYielding() { + return getController().getYieldController().isYieldActive(); + } + + private GameView getGameView() { + return getController().getGui().getGameView(); + } + + private PlayerView getPlayerView() { + return getOwner(); + } + + private boolean checkHasAvailableActions() { + Player player = getController().getPlayer(); + if (player == null) return false; + // Freshened upstream in chooseSpellAbilityToPlay; don't recompute + return player.getView().hasAvailableActions(); + } + + private boolean shouldShowStackYieldPrompt() { + GameView gv = getGameView(); + if (gv == null) return false; + FCollectionView stack = gv.getStack(); + if (stack == null || stack.isEmpty()) return false; + return !checkHasAvailableActions(); + } + + /** Stack non-empty disqualifies; SUPPRESS_ON_OWN_TURN suppresses on own turn (after first round). */ + private boolean isValidSuggestionContext(GameView gv, PlayerView pv) { + FCollectionView stack = gv.getStack(); + if (stack != null && !stack.isEmpty()) return false; + PlayerView currentTurn = gv.getPlayerTurn(); + if (currentTurn != null && currentTurn.equals(pv)) { + // Always suppress on player's first turn (no lands/mana yet) + int numPlayers = gv.getPlayers().size(); + if (gv.getTurn() <= numPlayers) return false; + if (getController().getYieldController().getBoolPref(FPref.YIELD_SUPPRESS_ON_OWN_TURN)) return false; + } + return true; + } + + private boolean shouldShowNoActionsPrompt() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + if (!isValidSuggestionContext(gv, pv)) return false; + return !checkHasAvailableActions(); + } + /** {@inheritDoc} */ @Override protected final void onOk() { + if (pendingSuggestion != null) { + // Defensive: state may have flipped (e.g. async multiplayer click). + if (isAlreadyYielding()) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + stop(); + return; + } + // APINA flipped on between display and accept — drop the now-redundant accept (host-local only) + if (!getController().isRemoteClient() + && getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + stop(); + return; + } + SuggestionType accepted = pendingSuggestion; + pendingSuggestion = null; + pendingSuggestionMessage = null; + + PlayerView self = getPlayerView(); + YieldController yc = getController().getYieldController(); + if (accepted == SuggestionType.STACK_YIELD) { + yc.setAutoPassUntilStackEmpty(true); + if (self != null) getController().getGui().applyYieldUpdate( + new YieldUpdate.StackYield(self, true)); + } else if (accepted == SuggestionType.NO_ACTIONS) { + // UPKEEP because UNTAP has no priority pass — a marker on UNTAP could never fire. + if (self != null) { + boolean atOrPast = YieldController.isPriorityAtOrPastMarker( + getGameView(), self, PhaseType.UPKEEP); + yc.setMarker(self, PhaseType.UPKEEP, atOrPast); + getController().getGui().applyYieldUpdate( + new YieldUpdate.SetMarker(self, PhaseType.UPKEEP, atOrPast)); + } + } + if (isAlreadyYielding()) { + stop(); + } else { + showNormalPrompt(); + } + return; + } + passPriority(() -> { getController().macros().addRememberedAction(new PassPriorityAction()); stop(); @@ -94,6 +276,14 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { + if (pendingSuggestion != null) { + getController().getYieldController().declineSuggestion(pendingSuggestion); + pendingSuggestion = null; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + if (!getController().tryUndoLastAction()) { //undo if possible //otherwise end turn passPriority(() -> { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 62978501ed5..cba3d52f897 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -13,6 +13,7 @@ import forge.interfaces.IDevModeCheats; import forge.interfaces.IGameController; import forge.interfaces.IMacroSystem; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import forge.util.ITriggerEvent; @@ -188,15 +189,7 @@ public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType ph @Override public void applyYieldUpdate(final YieldUpdate update) { - // Local self-apply for marker/stack-yield user actions that route through - // sendYieldUpdate. Other cases dispatch via dedicated setters above. - if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); - } else if (update instanceof YieldUpdate.ClearMarker) { - yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.StackYield u) { - yieldController.setAutoPassUntilStackEmpty(u.active()); - } + yieldController.apply(update); } /** @@ -218,6 +211,18 @@ public void seedYieldStateOnHost(Map> skipPhases) send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); } + @Override + public void setYieldBoolPref(final FPref pref, final boolean value) { + yieldController.setBoolPref(pref, value); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldBoolPref(pref, value)); + } + + @Override + public void setYieldStringPref(final FPref pref, final String value) { + yieldController.setStringPref(pref, value); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldStringPref(pref, value)); + } + private IMacroSystem macros; @Override public IMacroSystem macros() { diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index 96ef7706176..1760194ceee 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -4,10 +4,13 @@ import com.google.common.collect.Maps; import com.google.common.collect.Multimap; import com.google.common.eventbus.Subscribe; +import forge.game.GameView; import forge.game.card.CardView; import forge.game.event.*; import forge.game.player.PlayerView; +import forge.game.spellability.SpellAbilityStackInstance; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldController; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; import forge.localinstance.properties.ForgeConstants; @@ -259,6 +262,8 @@ public Void visit(final GameEventGameFinished ev) { @Override public Void visit(final GameEventSpellAbilityCast event) { + evaluateYieldInterruptForSpellCast(event); + needStackUpdate = true; if (GuiBase.getInterface().isLibgdxPort() || ForgeConstants.STACK_EFFECT_NOTIFICATION_NEVER.equals(FModel.getPreferences().getPref(FPref.UI_STACK_EFFECT_NOTIFICATION_POLICY))) { @@ -273,6 +278,22 @@ public Void visit(final GameEventSpellAbilityCast event) { return null; } + private void evaluateYieldInterruptForSpellCast(GameEventSpellAbilityCast event) { + if (humanController == null) return; + YieldController yc = humanController.getYieldController(); + if (yc == null || !yc.isYieldActive()) return; + GameView gv = matchController.getGameView(); + if (gv == null || gv.getGame() == null) return; + // Look up the actual SpellAbilityStackInstance by id (host-side; client gv.getGame() is null). + int targetId = event.si() != null ? event.si().getId() : -1; + if (targetId < 0) return; + SpellAbilityStackInstance si = null; + for (SpellAbilityStackInstance candidate : gv.getGame().getStack()) { + if (candidate.getId() == targetId) { si = candidate; break; } + } + if (si != null) yc.onSpellAbilityCast(si, gv); + } + @Override public Void visit(final GameEventSpellResolved event) { needStackUpdate = true; @@ -343,7 +364,6 @@ public Void visit(final GameEventCardCounters event) { @Override public Void visit(final GameEventBlockersDeclared event) { final Set cards = new HashSet<>(); - for (final Multimap kv : event.blockers().values()) { cards.addAll(kv.values()); } @@ -352,6 +372,13 @@ public Void visit(final GameEventBlockersDeclared event) { @Override public Void visit(final GameEventAttackersDeclared event) { + if (humanController != null) { + YieldController yc = humanController.getYieldController(); + if (yc != null && yc.isYieldActive()) { + GameView gv = matchController.getGameView(); + if (gv != null && gv.getCombat() != null) yc.onAttackersDeclared(gv.getCombat()); + } + } return processCards(event.attackersMap().values(), cardsUpdate); } diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index d10bb1032d8..81f6d3b7382 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -8,6 +8,7 @@ import forge.gamemodes.match.NextGameDecision; import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldUpdate; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.util.ITriggerEvent; public interface IGameController { @@ -83,4 +84,8 @@ default void sendYieldUpdate(YieldUpdate update) { } YieldController getYieldController(); + + /** Setter dispatches the per-PCH envelope; reads go via getYieldController(). */ + void setYieldBoolPref(FPref pref, boolean value); + void setYieldStringPref(FPref pref, String value); } diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index c427e4bd2b9..51c45a29528 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -144,6 +144,21 @@ public enum FPref implements PreferencesStore.IPref { UI_ALT_PLAYERINFOLAYOUT ("false"), UI_ALT_PLAYERZONETABS ("false"), UI_PRESELECT_PREVIOUS_ABILITY_ORDER ("false"), + YIELD_INTERRUPT_ON_ATTACKERS ("true"), + YIELD_INTERRUPT_ON_OPPONENT_SPELL ("true"), + YIELD_INTERRUPT_ON_TARGETING ("false"), + YIELD_INTERRUPT_ON_TRIGGERS ("false"), + YIELD_INTERRUPT_ON_REVEAL ("false"), + YIELD_INTERRUPT_ON_MASS_REMOVAL ("false"), + YIELD_AUTO_PASS_NO_ACTIONS ("false"), + YIELD_AUTO_PASS_RESPECTS_INTERRUPTS ("false"), + YIELD_AVAILABLE_ACTIONS_BUDGET_MS ("0"), + YIELD_SKIP_PHASE_DELAY ("false"), + YIELD_SKIP_RESOLVE_DELAY ("false"), + YIELD_SUPPRESS_ON_OWN_TURN ("true"), + YIELD_SUPPRESS_AFTER_END ("true"), + YIELD_DECLINE_SCOPE_STACK_YIELD ("NEVER"), + YIELD_DECLINE_SCOPE_NO_ACTIONS ("NEVER"), UI_AUTO_YIELD_MODE (ForgeConstants.AUTO_YIELD_PER_ABILITY), UI_SHOW_STORM_COUNT_IN_PROMPT ("false"), UI_REMIND_ON_PRIORITY ("false"), @@ -306,6 +321,9 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_SHOWTARGETING ("84"), SHORTCUT_AUTOYIELD_ALWAYS_YES ("89"), SHORTCUT_AUTOYIELD_ALWAYS_NO ("78"), + SHORTCUT_YIELD_OPTIONS ("17 89"), + SHORTCUT_YIELD_AUTO_PASS ("113"), + SHORTCUT_YIELD_CANCEL ("27"), SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 14c8e631eb5..a0d2042710e 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3,8 +3,10 @@ import com.google.common.collect.*; import forge.LobbyPlayer; import forge.StaticData; +import forge.ai.AvailableActions; import forge.ai.GameState; import forge.ai.PlayerControllerAi; +import forge.gamemodes.net.server.RemoteClientGuiGame; import forge.card.*; import forge.card.mana.ManaCost; import forge.card.mana.ManaCostShard; @@ -47,6 +49,7 @@ import forge.game.zone.PlayerZone; import forge.game.zone.Zone; import forge.game.zone.ZoneType; +import forge.gamemodes.match.DeclineScope; import forge.gamemodes.match.NextGameDecision; import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldUpdate; @@ -927,6 +930,7 @@ public void reveal(final List cards, final ZoneType zone, final Player } protected void reveal(final CardCollectionView cards, final ZoneType zone, final PlayerView owner, String message, boolean addSuffix) { + yieldController.maybeInterruptOnReveal(); if (StringUtils.isBlank(message)) { message = localizer.getMessage("lblLookCardInPlayerZone", "{player's}", zone.getTranslatedName().toLowerCase()); } else if (addSuffix) { @@ -1476,8 +1480,6 @@ public void declareAttackers(final Player attackingPlayer, final Combat combat) return; // don't prompt to declare attackers if user chose to // end the turn and not attacking is legal } - // otherwise: cancel auto pass because of this unexpected attack - autoPassCancel(); } // This input should not modify combat object itself, but should return user choice @@ -1499,7 +1501,19 @@ public List chooseSpellAbilityToPlay() { player.getName(), getGame().getPhaseHandler().getPhase(), getGame().isGameOver()); final MagicStack stack = getGame().getStack(); - if (mayAutoPass()) { + // Skip when already yielding — yield proceeds regardless of available-actions. + // Yield check first: it's a field read, vs needsAvailableActions which does 3 synced FPref reads. + if (!yieldController.isYieldActive() && needsAvailableActions()) { + long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); + boolean result = AvailableActions.compute(getPlayer(), timeoutMs); + getPlayer().getView().setHasAvailableActions(result); + } + + // Game-thread only; no cross-thread races on the YIELD_SUPPRESS_AFTER_END flag + boolean nowMayAutoPass = mayAutoPass(); + yieldController.noteMayAutoPassResult(nowMayAutoPass); + + if (nowMayAutoPass) { // avoid prompting for input if current phase is set to be // auto-passed instead posing a short delay if needed to // prevent the game jumping ahead too quick @@ -1507,10 +1521,11 @@ public List chooseSpellAbilityToPlay() { if (stack.isEmpty()) { // make sure to briefly pause at phases you're not set up to skip if (!isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), - getGame().getPhaseHandler().getPhase())) { + getGame().getPhaseHandler().getPhase()) + && !yieldController.getBoolPref(FPref.YIELD_SKIP_PHASE_DELAY)) { delay = FControlGamePlayback.phasesDelay; } - } else { + } else if (!yieldController.getBoolPref(FPref.YIELD_SKIP_RESOLVE_DELAY)) { // pause slightly longer for spells and abilities on the stack resolving delay = FControlGamePlayback.resolveDelay; } @@ -1705,6 +1720,7 @@ public Pair chooseTarget(final SpellAbili @Override public void notifyOfValue(final SpellAbility sa, final GameObject realtedTarget, final String value) { + yieldController.maybeInterruptOnReveal(); final String message = MessageUtil.formatNotificationMessage(sa, player, realtedTarget, value); if (sa != null && sa.isManaAbility()) { getGame().fireEvent(new GameEventAddLog(GameLogEntryType.LAND, message)); @@ -3459,15 +3475,16 @@ public void requestResync() { } public boolean isRemoteClient() { - return gui instanceof forge.gamemodes.net.server.RemoteClientGuiGame; + return gui instanceof RemoteClientGuiGame; } public boolean mayAutoPass() { - return yieldController.shouldAutoYield(); + return yieldController.shouldAutoYield() + || yieldController.isAutoPassingNoActions(getLocalPlayerView()); } public void autoPassUntilEndOfTurn() { - yieldController.setAutoPassUntilEOTWithoutInterruptions(true); + yieldController.setAutoPassUntilEndOfTurn(true); getGui().updateAutoPassPrompt(); } @@ -3476,7 +3493,7 @@ public void autoPassCancel() { if (!mayAutoPass()) { return; } - yieldController.setAutoPassUntilEOTWithoutInterruptions(false); + yieldController.setAutoPassUntilEndOfTurn(false); PlayerView playerView = getLocalPlayerView(); getGui().showPromptMessage(playerView, ""); getGui().updateButtons(playerView, false, false, false); @@ -3539,29 +3556,55 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p @Override public void applyYieldUpdate(final YieldUpdate update) { - boolean activatedYield = false; - if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); - activatedYield = true; - } else if (update instanceof YieldUpdate.ClearMarker) { - yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.StackYield u) { - yieldController.setAutoPassUntilStackEmpty(u.active()); - activatedYield = u.active(); - } else if (update instanceof YieldUpdate.TriggerDecision u) { - yieldController.setTriggerDecision(u.trigId(), u.decision()); - } else if (update instanceof YieldUpdate.CardAutoYield u) { - yieldController.applyAutoYieldFromWire(u.cardKey(), u.active()); - } else if (update instanceof YieldUpdate.SkipPhase u) { - yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); - } else if (update instanceof YieldUpdate.SeedFromClient u) { - yieldController.applyClientSeed(u.snapshot()); - } - if (activatedYield) { - // Switch the cancel button + prompt to "Yielding until X" so the user can disarm. - // Otherwise the previous InputPassPriority "End Turn" label would persist and ESC - // would skip the click on the client. + if (yieldController.apply(update)) { + // Refresh prompt so cancel button reflects "Yielding until X" instead of stale "End Turn". getGui().updateAutoPassPrompt(); } + tryAutoPassNow(); + } + + @Override + public void setYieldBoolPref(final FPref pref, final boolean value) { + yieldController.setBoolPref(pref, value); + tryAutoPassNow(); + } + + @Override + public void setYieldStringPref(final FPref pref, final String value) { + yieldController.setStringPref(pref, value); + } + + /** Re-evaluate mayAutoPass at the current prompt; click OK if it would now fire. + * Same compute gating as {@link #chooseSpellAbilityToPlay} so the actions field is fresh. */ + private void tryAutoPassNow() { + if (!(inputQueue.getInput() instanceof InputPassPriority)) return; + if (yieldController.isYieldActive()) return; + if (needsAvailableActions()) { + long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + } + if (mayAutoPass()) { + selectButtonOk(); + } + } + + /** True iff some yield consumer needs the synced wire field. */ + private boolean needsAvailableActions() { + if (yieldController.getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return true; + if (yieldController.getDeclineScope(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS) != DeclineScope.NEVER) return true; + if (yieldController.getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) != DeclineScope.NEVER) return true; + return false; + } + + /** Pref > 0 = explicit override; 0 = Dynamic = 50ms × playable cards, clamped to [50, 1500]. */ + private long computeAvailableActionsBudgetMs(Player p) { + String prefStr = yieldController.getStringPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + int prefMs; + try { prefMs = Integer.parseInt(prefStr); } catch (NumberFormatException e) { prefMs = 0; } + if (prefMs > 0) return prefMs; + int cardCount = p.getCardsIn(ZoneType.Hand).size() + + p.getCardsIn(ZoneType.Battlefield).size() + + p.getCardsIn(ZoneType.Flashback).size(); + return Math.min(1500L, Math.max(50L, 50L * cardCount)); } } From 304456d0cceb5b0ad90f4604e69141754b215352 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 6 May 2026 06:54:37 +0930 Subject: [PATCH 02/36] Drop APINA state-scanner; route interrupts through yield event handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously APINA + RESPECTS_INTERRUPTS evaluated `shouldInterruptYield` on every priority tick — meaning a Wrath sitting on the stack caused APINA to re-prompt every priority window for as long as the spell stayed there. Yields had the right semantic (event-driven, one prompt per event); APINA duplicated the classifiers in a per-tick state-scanner that produced the wrong UX. APINA now shares the yield event path. Each event handler (`onSpellAbilityCast`, `onAttackersDeclared`, `maybeInterruptOnReveal`) calls a unified `applyInterrupt()` that clears any interruptible yield and sets a transient `autoPassInterrupted` flag when APINA + RESPECTS_INTERRUPTS are on. The flag is cleared by `noteMayAutoPassResult` once the user has been prompted, so APINA resumes on the next priority window without re-prompting. `shouldInterruptYield` and `hasMassRemovalOnStack` are deleted — both were only reachable from the APINA path. Side effect: REVEAL now applies to APINA. The state-scanner had no REVEAL check, so users with INTERRUPT_ON_REVEAL enabled were silently missing that interrupt under APINA; the event handler covers it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gamemodes/match/YieldController.java | 91 ++++++------------- 1 file changed, 29 insertions(+), 62 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 5b2b91d488d..aae8e93e4b9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -271,6 +271,7 @@ public synchronized void resetForNewGame() { lastSeenStackNonEmpty = false; wasAutoPassingLastTick = false; yieldJustEndedFlag = false; + autoPassInterrupted = false; // boolPrefOverrides / stringPrefOverrides intentionally kept — per-match, not per-game } @@ -413,13 +414,12 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { /** Eager check at REVEAL/notifyOfValue call sites — no GameEventReveal exists. */ public void maybeInterruptOnReveal() { - if (isInterruptibleYieldActive() && getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) { - clearActiveYieldAndDispatch(); - } + if (!getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) return; + applyInterrupt(); } public void onSpellAbilityCast(SpellAbilityStackInstance si, GameView gameView) { - if (!isInterruptibleYieldActive()) return; + if (!shouldEvaluateInterrupts()) return; if (si == null) return; PlayerView local = owner != null ? owner.getLocalPlayerView() : null; if (local == null) return; @@ -427,31 +427,47 @@ public void onSpellAbilityCast(SpellAbilityStackInstance si, GameView gameView) boolean isOpponent = activator != null && !activator.getView().equals(local); if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { - clearActiveYieldAndDispatch(); + applyInterrupt(); return; } StackItemView siv = StackItemView.get(si); if (siv != null && getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING) && targetsPlayerOrPermanents(siv, local)) { - clearActiveYieldAndDispatch(); + applyInterrupt(); return; } if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) && isMassRemovalInstance(si)) { - clearActiveYieldAndDispatch(); + applyInterrupt(); return; } if (si.isTrigger() && getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS)) { - clearActiveYieldAndDispatch(); + applyInterrupt(); } } public void onAttackersDeclared(CombatView combat) { - if (!isInterruptibleYieldActive()) return; + if (!shouldEvaluateInterrupts()) return; PlayerView local = owner != null ? owner.getLocalPlayerView() : null; if (local == null) return; if (getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS) && isBeingAttacked(combat, local)) { - clearActiveYieldAndDispatch(); + applyInterrupt(); + } + } + + /** True when interrupt classifiers should run — an interruptible yield is active, or APINA is active with respects-interrupts on. */ + private boolean shouldEvaluateInterrupts() { + if (isInterruptibleYieldActive()) return true; + return getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) + && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS); + } + + /** Apply an interrupt: clear any interruptible yield and pause APINA for one prompt. Either, both, or neither may apply. */ + public synchronized void applyInterrupt() { + if (isInterruptibleYieldActive()) clearActiveYieldAndDispatch(); + if (getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) + && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS)) { + autoPassInterrupted = true; } } @@ -459,6 +475,7 @@ public void onAttackersDeclared(CombatView combat) { private boolean lastSeenStackNonEmpty = false; private boolean wasAutoPassingLastTick = false; private boolean yieldJustEndedFlag = false; + private boolean autoPassInterrupted = false; public synchronized void onPriorityReceived(boolean stackNonEmpty) { if (lastSeenStackNonEmpty && !stackNonEmpty) { @@ -493,6 +510,7 @@ public synchronized boolean isSuggestionDeclined(SuggestionType type) { public synchronized void noteMayAutoPassResult(boolean nowMayAutoPass) { if (wasAutoPassingLastTick && !nowMayAutoPass) yieldJustEndedFlag = true; wasAutoPassingLastTick = nowMayAutoPass; + if (!nowMayAutoPass) autoPassInterrupted = false; } /** Self-clearing read of the mayAutoPass true→false edge. */ @@ -505,9 +523,7 @@ public synchronized boolean didYieldJustEnd() { /** Per-tick predicate (distinct from one-shot yields). Used by mayAutoPass. */ public boolean isAutoPassingNoActions(PlayerView player) { if (!getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return false; - if (getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS) && shouldInterruptYield(player)) { - return false; - } + if (autoPassInterrupted) return false; GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; if (gv != null && gv.getStack() != null && gv.getStack().isEmpty()) { PlayerView turnPlayer = gv.getPlayerTurn(); @@ -520,55 +536,6 @@ public boolean isAutoPassingNoActions(PlayerView player) { return player != null && !player.hasAvailableActions(); } - /** State-scanner for APINA's RESPECTS_INTERRUPTS only — event-driven flow uses classifiers directly. */ - public boolean shouldInterruptYield(PlayerView player) { - if (player == null) return false; - GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; - if (gv == null) return false; - PhaseType phase = gv.getPhase(); - CombatView combat = gv.getCombat(); - - if (getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS) - && phase == PhaseType.COMBAT_DECLARE_ATTACKERS - && combat != null && isBeingAttacked(combat, player)) { - return true; - } - FCollectionView stack = gv.getStack(); - if (getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING) && stack != null) { - for (StackItemView si : stack) { - if (targetsPlayerOrPermanents(si, player)) return true; - } - } - if (getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { - StackItemView top = gv.peekStack(); - if (top != null) { - PlayerView act = top.getActivatingPlayer(); - if (act != null && !act.equals(player)) return true; - } - } - if (getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) && hasMassRemovalOnStack(gv, player)) { - return true; - } - if (getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS) && stack != null) { - for (StackItemView si : stack) { - if (si.isTrigger()) return true; - } - } - return false; - } - - /** Host-only: walks live engine stack via gameView.getGame(). Opponent spells only. */ - private static boolean hasMassRemovalOnStack(GameView gameView, PlayerView player) { - forge.game.Game game = gameView.getGame(); - if (game == null) return false; // host-only path; defensive on the client - for (SpellAbilityStackInstance si : game.getStack()) { - Player activator = si.getActivatingPlayer(); - if (activator == null || activator.getView().equals(player)) continue; - if (isMassRemovalInstance(si)) return true; - } - return false; - } - private static boolean isBeingAttacked(CombatView combatView, PlayerView player) { if (combatView == null) return false; FCollection attackersOfPlayer = combatView.getAttackersOf(player); From f8436a908a75f910e8d7184f522ba55c907c8ca0 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 6 May 2026 20:49:56 +0930 Subject: [PATCH 03/36] Move auto-pass and yield settings to the dock panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated yield options panel was a tab in the prompt cell hosting just two controls (Auto-Pass toggle + Settings dialog launcher). Fold both into the existing dock — Auto-Pass becomes an icon with a goldenrod- highlighted toggled state, and the previously-dormant cog button now opens VYieldSettings. Promote BUTTON_DOCK to its own match.xml cell directly above the prompt panel, aligning the prompt top with the hand top. Drop the in-dock Revert/Open/Save Layout buttons (still available from the menu bar's Layout menu). Update the Advanced Yield Options wiki page to match the new access points. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/advanced-yield-options.md | 8 +- .../java/forge/control/KeyboardShortcuts.java | 5 +- .../main/java/forge/gui/framework/EDocID.java | 1 - .../java/forge/screens/match/CMatchUI.java | 21 ---- .../screens/match/controllers/CDock.java | 22 ++-- .../screens/match/controllers/CYield.java | 96 ----------------- .../java/forge/screens/match/views/VDock.java | 46 ++++---- .../forge/screens/match/views/VYield.java | 101 ------------------ forge-gui/res/defaults/match.xml | 9 +- forge-gui/res/languages/en-US.properties | 3 +- .../forge/localinstance/skin/FSkinProp.java | 1 + 11 files changed, 52 insertions(+), 261 deletions(-) delete mode 100644 forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java delete mode 100644 forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index 82c1f93c2a9..fe002692b90 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -23,18 +23,18 @@ These features are highly configurable through the **Yield Settings** dialog, an ## How to Access -- **Desktop:** The **Yield Options** tab appears in your match UI in the prompt panel. You can also open the Game menu > **Yield Settings**, or press Ctrl+Y. +- **Desktop:** The Auto-Pass icon and a yield-settings cog appear on the dock panel above the prompt area. Click the cog, open the Game menu > **Yield Settings**, or press Ctrl+Y to bring up the full settings dialog. Click the Auto-Pass icon (or press F2) to toggle Auto-Pass — its background lights up gold while active. - **Mobile:** open the in-match Game menu > **Yield Options**. ## Auto-Pass -**Auto-Pass** is a persistent toggle (F2 on desktop, or the Auto-Pass button) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. +**Auto-Pass** is a persistent toggle (F2 on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. **How it works:** - When enabled, Forge scans your hand, battlefield, and external zones (graveyard, exile, command) for castable spells, playable lands, and activatable abilities. - If you have any available action, you keep priority as usual. - If you have no available action, Forge passes priority on your behalf without prompting. -- The button label reflects the state (`Auto-Pass: ON` / `Auto-Pass: OFF`). +- On desktop, the Auto-Pass dock icon's background lights up gold while active. On mobile, the menu entry text reads `Auto-Pass: ON` / `Auto-Pass: OFF`. **Interaction with interrupts:** By default, Auto-Pass ignores your interrupt settings — it keeps passing as long as you have no actions, regardless of attackers, opponent spells, mass-removal, etc. Enable **Auto-pass respects interrupts** in the Yield Interrupt Settings section if you want interrupts to break Auto-Pass too. @@ -67,7 +67,7 @@ A fast-forward symbol will appear on the targeted cell to show the marker is act ## Yield Settings Menu The **Yield Settings** dialog is the central configuration UI for yield behavior. It's accessible from: -- **Desktop:** the **...** button on the Yield Options panel, the Game menu > **Yield Settings** entry, or Ctrl+Y. +- **Desktop:** the cog button on the dock, the Game menu > **Yield Settings** entry, or Ctrl+Y. - **Mobile:** Game menu > **Yield Options**. The dialog has three sections: diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index fd6de4a604b..82467245cfb 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -25,6 +25,7 @@ import forge.interfaces.IGameController; import forge.gui.framework.SDisplayUtil; import forge.localinstance.properties.ForgePreferences; +import forge.gamemodes.match.YieldController; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.screens.home.settings.VSubmenuPreferences.KeyboardShortcutField; @@ -278,8 +279,8 @@ public void actionPerformed(final ActionEvent e) { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null || matchUI.getCYield() == null) { return; } - matchUI.getCYield().toggleAutoPass(); + if (matchUI == null) { return; } + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); } }; diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java index 72dcf8d68c7..dabd0d5968e 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java @@ -94,7 +94,6 @@ public enum EDocID { REPORT_COMBAT (), REPORT_DEPENDENCIES (), REPORT_LOG (), - REPORT_YIELD (), DEV_MODE (), BUTTON_DOCK (), diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index ca226685ac5..d414fae2253 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -105,7 +105,6 @@ import forge.screens.match.controllers.CDev; import forge.screens.match.controllers.CDock; import forge.screens.match.controllers.CLog; -import forge.screens.match.controllers.CYield; import forge.screens.match.controllers.CPrompt; import forge.screens.match.controllers.CStack; import forge.screens.match.menus.CMatchUIMenus; @@ -176,8 +175,6 @@ public final class CMatchUI private final CLog cLog = new CLog(this); private final CPrompt cPrompt = new CPrompt(this); private final CStack cStack = new CStack(this); - private final CYield cYield = new CYield(this); - public CYield getCYield() { return cYield; } private int nextNotifiableStackIndex = 0; public CMatchUI() { @@ -197,7 +194,6 @@ public CMatchUI() { this.myDocs.put(EDocID.REPORT_COMBAT, cCombat.getView()); this.myDocs.put(EDocID.REPORT_DEPENDENCIES, cDependencies.getView()); this.myDocs.put(EDocID.REPORT_LOG, cLog.getView()); - this.myDocs.put(EDocID.REPORT_YIELD, cYield.getView()); this.myDocs.put(EDocID.DEV_MODE, getCDev().getView()); this.myDocs.put(EDocID.BUTTON_DOCK, getCDock().getView()); } @@ -878,7 +874,6 @@ public void updatePlayerControl() { FloatingZone.registerZoneDocs(this, getLocalPlayers()); SLayoutIO.loadLayout(null); FloatingZone.pruneUnparentedDocks(); - ensureNewDocsVisible(); view.populate(); final PlayerZoneUpdates zones = new PlayerZoneUpdates(); for (final PlayerView p : sortedPlayers) { @@ -887,22 +882,6 @@ public void updatePlayerControl() { updateZones(zones); } - /** - * Adds new EDocIDs introduced after the user's saved layout was created - * so existing users see them without resetting their layout. Currently: - * REPORT_YIELD → prompt cell (the cell hosting REPORT_MESSAGE). - */ - private void ensureNewDocsVisible() { - final IVDoc yieldDoc = EDocID.REPORT_YIELD.getDoc(); - if (yieldDoc != null && yieldDoc.getParentCell() == null) { - final IVDoc promptDoc = EDocID.REPORT_MESSAGE.getDoc(); - final DragCell promptCell = promptDoc == null ? null : promptDoc.getParentCell(); - if (promptCell != null) { - promptCell.addDoc(yieldDoc); - } - } - } - @Override public void disableOverlay() { showOverlay = false; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java index 320beb89265..29d37bb3894 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java @@ -23,14 +23,14 @@ import com.google.common.primitives.Ints; import forge.Singletons; -import forge.gui.SOverlayUtils; +import forge.gamemodes.match.YieldController; import forge.gui.UiCommand; import forge.gui.framework.ICDoc; -import forge.gui.framework.SLayoutIO; import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; import forge.model.FModel; import forge.screens.match.CMatchUI; +import forge.screens.match.VYieldSettings; import forge.screens.match.views.VDock; import forge.toolbox.FSkin; import forge.util.Localizer; @@ -129,14 +129,23 @@ public void initialize() { refreshArcStateDisplay(); view.getBtnConcede().setCommand((UiCommand) matchUI::concede); - view.getBtnSettings().setCommand((UiCommand) SOverlayUtils::showOverlay); + view.getBtnSettings().setCommand((UiCommand) () -> new VYieldSettings(matchUI).showDialog()); view.getBtnEndTurn().setCommand((UiCommand) this::endTurn); + view.getBtnAutoPass().setCommand((UiCommand) this::toggleAutoPass); view.getBtnViewDeckList().setCommand((UiCommand) matchUI::viewDeckList); - view.getBtnRevertLayout().setCommand((UiCommand) SLayoutIO::revertLayout); - view.getBtnOpenLayout().setCommand((UiCommand) SLayoutIO::openLayout); - view.getBtnSaveLayout().setCommand((UiCommand) SLayoutIO::saveLayout); view.getBtnAlphaStrike().setCommand((UiCommand) () -> matchUI.getGameController().alphaStrike()); view.getBtnTargeting().setCommand((UiCommand) this::toggleTargeting); + + refreshAutoPassToggled(); + } + + private void toggleAutoPass() { + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + refreshAutoPassToggled(); + } + + private void refreshAutoPassToggled() { + view.getBtnAutoPass().setToggled(FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); } /* (non-Javadoc) @@ -144,6 +153,7 @@ public void initialize() { */ @Override public void update() { + refreshAutoPassToggled(); } } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java deleted file mode 100644 index c09b47c238a..00000000000 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Forge: Play Magic: the Gathering. - * Copyright (C) 2011 Forge Team - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package forge.screens.match.controllers; - -import java.awt.event.ActionListener; - -import javax.swing.JButton; - -import forge.gamemodes.match.YieldController; -import forge.gui.framework.ICDoc; -import forge.localinstance.properties.ForgePreferences.FPref; -import forge.model.FModel; -import forge.screens.match.CMatchUI; -import forge.screens.match.VYieldSettings; -import forge.screens.match.views.VYield; -import forge.util.Localizer; - -/** - * Controls the yield panel in the match UI. - * - *

(C at beginning of class name denotes a control class.) - */ -public class CYield implements ICDoc { - - private final CMatchUI matchUI; - private final VYield view; - - private final ActionListener actAutoPass = evt -> toggleAutoPass(); - private final ActionListener actSettings = evt -> openSettings(); - - public CYield(final CMatchUI matchUI) { - this.matchUI = matchUI; - this.view = new VYield(this); - } - - public final CMatchUI getMatchUI() { - return matchUI; - } - - public final VYield getView() { - return view; - } - - @Override - public void register() { - } - - @Override - public void initialize() { - initButton(view.getBtnAutoPass(), actAutoPass); - initButton(view.getBtnSettings(), actSettings); - - updateAutoPassButtonLabel(); - } - - private void initButton(final JButton button, final ActionListener onClick) { - button.removeActionListener(onClick); - button.addActionListener(onClick); - } - - @Override - public void update() { - updateAutoPassButtonLabel(); - } - - private void openSettings() { - new VYieldSettings(matchUI).showDialog(); - } - - public void toggleAutoPass() { - YieldController.toggleAutoPassNoActions(matchUI != null ? matchUI.getGameController() : null); - updateAutoPassButtonLabel(); - } - - private void updateAutoPassButtonLabel() { - boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); - view.getBtnAutoPass().setToggled(!autoPassOn); - view.getBtnAutoPass().setText(Localizer.getInstance().getMessage( - autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass")); - } -} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java index 9714141b927..d8d035c9d3f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java @@ -53,12 +53,10 @@ public class VDock implements IVDoc { private final DragTab tab = new DragTab(localizer.getMessage("lblDock")); // Dock button instances private final DockButton btnConcede = new DockButton(FSkin.getIcon(FSkinProp.ICO_CONCEDE), localizer.getMessage("lblConcedeGame")); - private final DockButton btnSettings = new DockButton(FSkin.getIcon(FSkinProp.ICO_SETTINGS), localizer.getMessage("lblGameSettings")); + private final DockButton btnSettings = new DockButton(FSkin.getIcon(FSkinProp.ICO_SETTINGS), localizer.getMessage("lblYieldSettings")); private final DockButton btnEndTurn = new DockButton(FSkin.getIcon(FSkinProp.ICO_ENDTURN), localizer.getMessage("lblEndTurn")); private final DockButton btnViewDeckList = new DockButton(FSkin.getIcon(FSkinProp.ICO_DECKLIST), localizer.getMessage("lblViewDeckList")); - private final DockButton btnRevertLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_REVERTLAYOUT), localizer.getMessage("lblRevertLayout")); - private final DockButton btnOpenLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_OPENLAYOUT), localizer.getMessage("lblOpenLayout")); - private final DockButton btnSaveLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_SAVELAYOUT), localizer.getMessage("lblSaveLayout")); + private final DockButton btnAutoPass = new DockButton(FSkin.getIcon(FSkinProp.ICO_AUTOPASS), localizer.getMessage("lblYieldBtnAutoPassTooltip")); private final DockButton btnAlphaStrike = new DockButton(FSkin.getIcon(FSkinProp.ICO_ALPHASTRIKE), localizer.getMessage("lblAlphaStrike")); private final FLabel btnTargeting = new FLabel.Builder().icon(FSkin.getIcon(FSkinProp.ICO_ARCSOFF)) .hoverable(true).iconInBackground(true).iconScaleFactor(1.0).build(); @@ -82,19 +80,15 @@ public VDock(final CDock controller) { public void populate() { btnTargeting.setFocusable(false); // don't let the targeting arc switcher get focus final JPanel pnl = parentCell.getBody(); - // Mig layout does not support wrapping! - // http://stackoverflow.com/questions/5715833/how-do-you-make-miglayout-behave-like-wrap-layout pnl.setLayout(new FlowLayout(FlowLayout.CENTER, 10, 10)); - pnl.add(btnConcede); - //pnl.add(btnSettings); + pnl.add(btnAutoPass); + pnl.add(btnSettings); pnl.add(btnEndTurn); - pnl.add(btnViewDeckList); - pnl.add(btnRevertLayout); - pnl.add(btnOpenLayout); - pnl.add(btnSaveLayout); pnl.add(btnAlphaStrike); pnl.add(btnTargeting); + pnl.add(btnViewDeckList); + pnl.add(btnConcede); } /* (non-Javadoc) @@ -155,16 +149,8 @@ public DockButton getBtnViewDeckList() { return btnViewDeckList; } - public DockButton getBtnRevertLayout() { - return btnRevertLayout; - } - - public DockButton getBtnOpenLayout() { - return btnOpenLayout; - } - - public DockButton getBtnSaveLayout() { - return btnSaveLayout; + public DockButton getBtnAutoPass() { + return btnAutoPass; } public DockButton getBtnAlphaStrike() { @@ -184,9 +170,11 @@ public FLabel getBtnTargeting() { public class DockButton extends SkinnedLabel implements ILocalRepaint { private final SkinImage img; private final SkinColor hoverBG = FSkin.getColor(FSkin.Colors.CLR_HOVER); + private final Color toggledBG = new Color(218, 165, 32); private final Color defaultBG = new Color(0, 0, 0, 0); private final Color defaultBorderColor = new Color(0, 0, 0, 0); private UiCommand command; + private boolean toggled; private int w, h; /** @@ -233,6 +221,11 @@ public void setCommand(UiCommand command0) { this.command = command0; } + public void setToggled(final boolean t) { + this.toggled = t; + repaintSelf(); + } + @Override public void repaintSelf() { final Dimension d = getSize(); @@ -248,10 +241,15 @@ public void repaintSelf() { public void paintComponent(final Graphics g) { this.w = this.getWidth(); this.h = this.getHeight(); - g.setColor(this.getBackground()); + final boolean highlighted = this.toggled || this.getSkin().getBackground() == this.hoverBG; + if (this.toggled) { + g.setColor(this.toggledBG); + } else { + g.setColor(this.getBackground()); + } g.fillRect(0, 0, this.w, this.h); - if (this.getSkin().getBackground() == this.hoverBG) { + if (highlighted) { FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_BORDERS)); } else { diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java deleted file mode 100644 index 6d136543278..00000000000 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Forge: Play Magic: the Gathering. - * Copyright (C) 2011 Forge Team - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package forge.screens.match.views; - -import javax.swing.JPanel; - -import forge.gui.framework.DragCell; -import forge.gui.framework.DragTab; -import forge.gui.framework.EDocID; -import forge.gui.framework.IVDoc; -import forge.localinstance.properties.ForgePreferences.FPref; -import forge.model.FModel; -import forge.screens.match.controllers.CYield; -import forge.toolbox.FButton; -import forge.toolbox.FSkin; -import forge.util.Localizer; -import net.miginfocom.swing.MigLayout; - -/** - * Assembles Swing components of the yield controls panel. - * - *

(V at beginning of class name denotes a view class.) - */ -public class VYield implements IVDoc { - - private DragCell parentCell; - private final Localizer localizer = Localizer.getInstance(); - private final DragTab tab = new DragTab(localizer.getMessage("lblYieldOptions")); - - private final FButton btnAutoPass = new FButton(localizer.getMessage("lblYieldBtnAutoPass")); - private final FButton btnSettings = new FButton("..."); - - private final CYield controller; - - public VYield(final CYield controller) { - this.controller = controller; - - java.awt.Font smallFont = FSkin.getBoldFont(11).getBaseFont(); - btnAutoPass.setFont(smallFont); - btnSettings.setFont(smallFont); - - btnAutoPass.setToolTipText(localizer.getMessage("lblYieldBtnAutoPassTooltip")); - btnSettings.setToolTipText(localizer.getMessage("lblInterruptSettingsTooltip")); - } - - @Override - public void populate() { - JPanel container = parentCell.getBody(); - - boolean largerButtons = FModel.getPreferences().getPrefBoolean(FPref.UI_FOR_TOUCHSCREN); - String heightConstraint = largerButtons ? "h 40px:40px:60px" : "hmin 20px"; - - container.setLayout(new MigLayout("gap 1px!, insets 2px, fillx")); - - container.add(btnAutoPass, "growx, pushx, w 83%, " + heightConstraint + ", gaptop 2px"); - container.add(btnSettings, "w 17%, " + heightConstraint + ", gaptop 2px"); - } - - @Override - public void setParentCell(final DragCell cell0) { - this.parentCell = cell0; - } - - @Override - public DragCell getParentCell() { - return this.parentCell; - } - - @Override - public EDocID getDocumentID() { - return EDocID.REPORT_YIELD; - } - - @Override - public DragTab getTabLabel() { - return tab; - } - - @Override - public CYield getLayoutControl() { - return controller; - } - - public FButton getBtnAutoPass() { return btnAutoPass; } - public FButton getBtnSettings() { return btnSettings; } -} diff --git a/forge-gui/res/defaults/match.xml b/forge-gui/res/defaults/match.xml index 99ef999756c..314c77f3828 100644 --- a/forge-gui/res/defaults/match.xml +++ b/forge-gui/res/defaults/match.xml @@ -1,17 +1,18 @@ - + REPORT_STACK REPORT_COMBAT REPORT_LOG REPORT_DEPENDENCIES - + + BUTTON_DOCK + + REPORT_MESSAGE - REPORT_YIELD DEV_MODE CARD_ANTES - BUTTON_DOCK FIELD_1 diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 30163f69ecc..a8c216ac0fe 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1630,10 +1630,9 @@ lblYieldOptions=Yield Options lblYieldSettings=Yield Settings lblYieldBtnAutoPass=Auto-Pass: OFF lblYieldBtnAutoPassOn=Auto-Pass: ON -lblYieldBtnAutoPassTooltip=Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare. +lblYieldBtnAutoPassTooltip=Auto-pass: Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare. lblInterruptSettings=Yield Interrupt Settings lblInterruptSettingsDesc=Cancel an active yield and return priority to you when these game events occur. -lblInterruptSettingsTooltip=Configure interrupt conditions and automatic suggestions lblInterruptOnAttackers=When attackers declared against you lblInterruptOnTargeting=When targeted by spell or ability lblInterruptOnOpponentSpell=When opponent casts a spell or activates an ability diff --git a/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java b/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java index 95663eb9420..0a5fc57e43b 100644 --- a/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java +++ b/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java @@ -267,6 +267,7 @@ public enum FSkinProp { ICO_ARCSOFF (new int[] {240, 800, 80, 80}, PropType.ICON), ICO_ARCSON (new int[] {320, 800, 80, 80}, PropType.ICON), ICO_ARCSHOVER (new int[] {400, 800, 80, 80}, PropType.ICON), + ICO_AUTOPASS (new int[] {400, 720, 80, 80}, PropType.ICON), //choice-search-misc ICO_HDCHOICE (new int[] {2, 1792, 128, 128}, PropType.BUTTONS), From d3556800105cd1677fd013e74d8ae39ab068f406 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 6 May 2026 21:49:46 +0930 Subject: [PATCH 04/36] Split stack-yield into interruptible and non-interruptible variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack-item context menu now offers two choices on desktop and mobile. "Yield to stack" auto-passes until the stack empties but backs off when any of the existing yield-interrupt prefs trip (opponent spell, targeting, mass removal, triggers, reveal). "Resolve entire stack" preserves today's fire-and-forget behavior — only stack-empty turns it off. The "you cannot respond to the stack" suggestion defaults to the interruptible variant. Implemented as a single boolean flag (stackYieldRespectsInterrupts) paired with the existing autoPassUntilStackEmpty state on YieldController, with the flag carried over the wire as a third component on YieldUpdate.StackYield. Reuses the existing applyInterrupt() path — no new prefs introduced. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/advanced-yield-options.md | 4 ++-- .../java/forge/control/KeyboardShortcuts.java | 2 +- .../forge/screens/match/views/VPrompt.java | 2 +- .../forge/screens/match/views/VStack.java | 12 ++++++++++- .../src/forge/screens/match/views/VStack.java | 8 +++++++- forge-gui/res/languages/en-US.properties | 3 ++- .../gamemodes/match/YieldController.java | 20 ++++++++++++++----- .../forge/gamemodes/match/YieldUpdate.java | 2 +- .../match/input/InputPassPriority.java | 4 ++-- 9 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index fe002692b90..c9339383266 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -74,7 +74,7 @@ The dialog has three sections: ### Yield Interrupt Settings -Yield markers and end-of-turn yield automatically cancel when important game events occur. (Stack-yield is exempt — its purpose is to watch the stack resolve, so opponent spells hitting the stack do not cancel it.) +Yield markers, end-of-turn yield, and the interruptible **Yield to stack** automatically cancel when important game events occur. The non-interruptible **Resolve entire stack** is exempt — its purpose is to watch the stack resolve to completion, so opponent spells hitting the stack do not cancel it. You can decide which game events interrupt a yield: @@ -99,7 +99,7 @@ When the system detects situations where you likely cannot take action, it can p | Suggestion | When it appears | Suggested action | Decline scope options | |------------|-----------------|------------------|-----------------------| -| **Can't respond to stack** | You have no instant-speed responses available | Stack yield (auto-pass until stack empties) | Never (default) / Always / Once per stack / Once per turn | +| **Can't respond to stack** | You have no instant-speed responses available | Yield to stack (interruptible — auto-pass until stack empties or an interrupt fires) | Never (default) / Always / Once per stack / Once per turn | | **No actions available** | No playable cards or activatable abilities (not your turn, stack empty) | Yield to your next turn | Never (default) / Always / Once per turn | **Decline scope options:** diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 82467245cfb..c4cf2ebe934 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -294,7 +294,7 @@ public void actionPerformed(final ActionEvent e) { if (ctrl == null || local == null || ctrl.getYieldController() == null) { return; } if (!ctrl.getYieldController().isYieldActive()) { return; } ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false, false)); ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); } }; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 1d74656cdea..9483e34067d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -87,7 +87,7 @@ public void keyPressed(final KeyEvent e) { if (ctrl != null && local != null && ctrl.getYieldController() != null && ctrl.getYieldController().isYieldActive()) { ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false, false)); ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); return; } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java index 521c48a1ab4..7ea71940f88 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java @@ -298,6 +298,7 @@ private final class AbilityMenu extends JPopupMenu { private final JCheckBoxMenuItem jmiAutoYield; private final JCheckBoxMenuItem jmiAlwaysYes; private final JCheckBoxMenuItem jmiAlwaysNo; + private final JMenuItem jmiYieldToStack; private final JMenuItem jmiYieldToEntireStack; private StackItemView item; @@ -340,11 +341,20 @@ public AbilityMenu(){ }); add(jmiAlwaysNo); + jmiYieldToStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToStack")); + jmiYieldToStack.addActionListener(arg0 -> { + final PlayerView local = controller.getMatchUI().getCurrentPlayer(); + if (local == null) return; + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true, true)); + controller.getMatchUI().getGameController().passPriority(); + }); + add(jmiYieldToStack); + jmiYieldToEntireStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToEntireStack")); jmiYieldToEntireStack.addActionListener(arg0 -> { final PlayerView local = controller.getMatchUI().getCurrentPlayer(); if (local == null) return; - controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true)); + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true, false)); controller.getMatchUI().getGameController().passPriority(); }); add(jmiYieldToEntireStack); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VStack.java b/forge-gui-mobile/src/forge/screens/match/views/VStack.java index bfc37d27b74..cdb64197367 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VStack.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VStack.java @@ -326,10 +326,16 @@ protected void buildMenu() { })); } } + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToStack"), + Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, + e -> { + controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true, true)); + controller.passPriority(); + })); addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToEntireStack"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { - controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true)); + controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true, false)); controller.passPriority(); })); addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index a8c216ac0fe..cb04f6d6fb1 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1625,7 +1625,8 @@ lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. lblYieldingUntilPhaseFmt=Yielding until {0}''s {1}.\nYou may cancel this yield to take an action. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. -lblYieldToEntireStack=Yield to entire stack +lblYieldToStack=Yield to stack +lblYieldToEntireStack=Resolve entire stack lblYieldOptions=Yield Options lblYieldSettings=Yield Settings lblYieldBtnAutoPass=Auto-Pass: OFF diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index aae8e93e4b9..24bae19cefd 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -48,6 +48,8 @@ public class YieldController { private boolean autoPassUntilEOT; private boolean autoPassUntilStackEmpty; + /** When true, an active stack-yield is cleared by interrupt classifiers; when false, only stack-empty turns it off. */ + private boolean stackYieldRespectsInterrupts; private YieldMarker autoPassUntilMarker; /** Priority has passed through any non-target phase since marker activation. */ @@ -89,16 +91,18 @@ public YieldMarker getAutoPassUntilMarker() { // All mutators synchronized — fields touched from EDT, Netty, game thread. // Activating any yield type clears the others — only one yield type may be active at a time (APINA is orthogonal). - public synchronized void setAutoPassUntilStackEmpty(boolean active) { + public synchronized void setAutoPassUntilStackEmpty(boolean active, boolean respectsInterrupts) { if (active) { autoPassUntilEOT = false; clearMarker(); } this.autoPassUntilStackEmpty = active; + this.stackYieldRespectsInterrupts = active && respectsInterrupts; } public synchronized void setAutoPassUntilEndOfTurn(boolean active) { if (active) { autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; clearMarker(); } this.autoPassUntilEOT = active; @@ -135,6 +139,7 @@ public Map snapshotStringPrefs() { public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; if (phaseOwner == null || phase == null) { clearMarker(); return; @@ -165,6 +170,7 @@ public boolean shouldAutoYield() { if (autoPassUntilStackEmpty) { if (gv != null && gv.peekStack() != null) return true; autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; } if (autoPassUntilMarker != null && gv != null) { PlayerView turnPlayer = gv.getPlayerTurn(); @@ -264,6 +270,7 @@ public void clearAutoYields() { public synchronized void resetForNewGame() { autoPassUntilEOT = false; autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; autoPassUntilMarker = null; hasLeftMarker = false; activationOnMarker = false; @@ -348,7 +355,7 @@ public boolean apply(YieldUpdate update) { } else if (update instanceof YieldUpdate.ClearMarker) { clearMarker(); } else if (update instanceof YieldUpdate.StackYield u) { - setAutoPassUntilStackEmpty(u.active()); + setAutoPassUntilStackEmpty(u.active(), u.respectsInterrupts()); return u.active(); } else if (update instanceof YieldUpdate.SetAutoPassUntilEndOfTurn u) { setAutoPassUntilEndOfTurn(u.active()); @@ -373,9 +380,11 @@ public boolean isYieldActive() { return autoPassUntilEOT || autoPassUntilStackEmpty || autoPassUntilMarker != null; } - /** EOT and marker yields can be interrupted. Stack-yield is fire and forget — only stack-empty turns it off. */ + /** EOT, marker, and interruptible stack-yields back off via {@link #applyInterrupt()}. The non-interruptible stack-yield is fire-and-forget — only stack-empty turns it off. */ public boolean isInterruptibleYieldActive() { - return autoPassUntilEOT || autoPassUntilMarker != null; + return autoPassUntilEOT + || autoPassUntilMarker != null + || (autoPassUntilStackEmpty && stackYieldRespectsInterrupts); } public synchronized void clearActiveYieldAndDispatch() { @@ -390,7 +399,8 @@ public synchronized void clearActiveYieldAndDispatch() { } if (autoPassUntilStackEmpty) { autoPassUntilStackEmpty = false; - if (gui != null) gui.applyYieldUpdate(new YieldUpdate.StackYield(local, false)); + stackYieldRespectsInterrupts = false; + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.StackYield(local, false, false)); anyCleared = true; } if (autoPassUntilEOT) { diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index 3963d46382e..6d51acaee1c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -30,7 +30,7 @@ record SetMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick record ClearMarker(PlayerView player) implements YieldUpdate {} - record StackYield(PlayerView player, boolean active) implements YieldUpdate {} + record StackYield(PlayerView player, boolean active, boolean respectsInterrupts) implements YieldUpdate {} record SetAutoPassUntilEndOfTurn(PlayerView player, boolean active) implements YieldUpdate {} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java index fe4010c0b45..edced7906aa 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java @@ -246,9 +246,9 @@ && getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACT PlayerView self = getPlayerView(); YieldController yc = getController().getYieldController(); if (accepted == SuggestionType.STACK_YIELD) { - yc.setAutoPassUntilStackEmpty(true); + yc.setAutoPassUntilStackEmpty(true, true); if (self != null) getController().getGui().applyYieldUpdate( - new YieldUpdate.StackYield(self, true)); + new YieldUpdate.StackYield(self, true, true)); } else if (accepted == SuggestionType.NO_ACTIONS) { // UPKEEP because UNTAP has no priority pass — a marker on UNTAP could never fire. if (self != null) { From 415d0cbf0004b93cf309707b5d823cd7733c1131 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 6 May 2026 18:01:22 +0200 Subject: [PATCH 05/36] Fix merge --- .../main/java/forge/gamemodes/match/YieldController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index c2ccef985fc..62a045c6b29 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -404,11 +404,11 @@ public boolean apply(YieldUpdate update) { } else if (update instanceof YieldUpdate.CardAutoYield u) { applyAutoYieldFromWire(u.cardKey(), u.active()); } else if (update instanceof YieldUpdate.TriggerDecision u) { - setTriggerDecision(u.trigId(), u.decision()); + setTriggerDecision(u.storageKey(), u.decision(), u.abilityScope()); } else if (update instanceof YieldUpdate.SetDisableYields u) { - yieldController.setDisableAutoYields(u.disabled()); + setDisableAutoYields(u.disabled()); } else if (update instanceof YieldUpdate.SetDisableTriggers u) { - yieldController.setDisableAutoTriggers(u.disabled()); + setDisableAutoTriggers(u.disabled()); } else if (update instanceof YieldUpdate.SkipPhase u) { setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } else if (update instanceof YieldUpdate.SetYieldBoolPref u) { From 8fa44c9a3cc13aeac0558435ed635e0f8a9bed88 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 6 May 2026 18:45:05 +0200 Subject: [PATCH 06/36] Clean up start --- .../gamemodes/match/YieldController.java | 44 ++++++------------- .../gui/control/FControlGameEventHandler.java | 2 +- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 62a045c6b29..714a3fb6970 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -250,11 +250,6 @@ private static AutoYieldStore.Tier activeTier() { return AutoYieldStore.Tier.MATCH; } - /** Cache-mode write of a wire-received update. Storage key is already at the right shape. */ - public void applyAutoYieldFromWire(String storageKey, boolean active) { - activeStore().setYield(AutoYieldStore.Tier.GAME, storageKey, active); - } - public Iterable getAutoYields() { AutoYieldStore store = activeStore(); if (!tierAware()) return store.getYields(AutoYieldStore.Tier.GAME); @@ -323,11 +318,6 @@ public String setTriggerDecision(String key, AutoYieldStore.TriggerDecision deci return storageKey; } - /** Cache-mode write of a wire-received trigger decision. Storage key is already at the right shape. */ - public void applyTriggerDecisionFromWire(String storageKey, AutoYieldStore.TriggerDecision decision) { - activeStore().setTriggerDecision(AutoYieldStore.Tier.GAME, storageKey, decision); - } - public Iterable> getAutoTriggers() { if (!tierAware()) return activeStore().getAutoTriggers(AutoYieldStore.Tier.GAME); if (activeModeIsInstall()) return PersistentAutoDecisionStore.get().getAutoTriggers(); @@ -402,9 +392,9 @@ public boolean apply(YieldUpdate update) { setAutoPassUntilEndOfTurn(u.active()); return u.active(); } else if (update instanceof YieldUpdate.CardAutoYield u) { - applyAutoYieldFromWire(u.cardKey(), u.active()); + activeStore().setYield(AutoYieldStore.Tier.GAME, u.cardKey(), u.active()); } else if (update instanceof YieldUpdate.TriggerDecision u) { - setTriggerDecision(u.storageKey(), u.decision(), u.abilityScope()); + activeStore().setTriggerDecision(AutoYieldStore.Tier.GAME, u.storageKey(), u.decision()); } else if (update instanceof YieldUpdate.SetDisableYields u) { setDisableAutoYields(u.disabled()); } else if (update instanceof YieldUpdate.SetDisableTriggers u) { @@ -473,9 +463,8 @@ public void maybeInterruptOnReveal() { applyInterrupt(); } - public void onSpellAbilityCast(SpellAbilityStackInstance si, GameView gameView) { + public void onSpellAbilityCast(SpellAbilityStackInstance si) { if (!shouldEvaluateInterrupts()) return; - if (si == null) return; PlayerView local = owner != null ? owner.getLocalPlayerView() : null; if (local == null) return; Player activator = si.getActivatingPlayer(); @@ -492,7 +481,7 @@ && targetsPlayerOrPermanents(siv, local)) { return; } if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) - && isMassRemovalInstance(si)) { + && isMassRemoval(si)) { applyInterrupt(); return; } @@ -593,14 +582,12 @@ public boolean isAutoPassingNoActions(PlayerView player) { private static boolean isBeingAttacked(CombatView combatView, PlayerView player) { if (combatView == null) return false; - FCollection attackersOfPlayer = combatView.getAttackersOf(player); - if (attackersOfPlayer != null && !attackersOfPlayer.isEmpty()) return true; + if (!combatView.getAttackersOf(player).isEmpty()) return true; for (forge.game.GameEntityView defender : combatView.getDefenders()) { if (defender instanceof CardView cardDefender) { PlayerView controller = cardDefender.getController(); if (controller != null && controller.equals(player)) { - FCollection attackers = combatView.getAttackersOf(defender); - if (attackers != null && !attackers.isEmpty()) return true; + if (!combatView.getAttackersOf(defender).isEmpty()) return true; } } } @@ -618,7 +605,7 @@ private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView pl FCollectionView targetCards = si.getTargetCards(); if (targetCards != null) { for (CardView target : targetCards) { - if (target.getController() != null && target.getController().equals(player)) return true; + if (player.equals(target.getController())) return true; } } StackItemView subInstance = si.getSubInstance(); @@ -627,17 +614,14 @@ private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView pl } /** Recurses into sub-instances for modal spells like Farewell. */ - private static boolean isMassRemovalInstance(SpellAbilityStackInstance si) { - SpellAbility sa = si.getSpellAbility(); - if (sa != null && isMassRemovalApi(sa.getApi())) return true; + private static boolean isMassRemoval(SpellAbilityStackInstance si) { + ApiType api = si.getSpellAbility().getApi(); + if (api == ApiType.DestroyAll + || api == ApiType.DamageAll + || api == ApiType.SacrificeAll + || api == ApiType.ChangeZoneAll) return true; SpellAbilityStackInstance subInstance = si.getSubInstance(); - return subInstance != null && isMassRemovalInstance(subInstance); + return subInstance != null && isMassRemoval(subInstance); } - private static boolean isMassRemovalApi(ApiType api) { - return api == ApiType.DestroyAll - || api == ApiType.DamageAll - || api == ApiType.SacrificeAll - || api == ApiType.ChangeZoneAll; - } } diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index 8584f5c35c7..731c5ff5e7f 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -291,7 +291,7 @@ private void evaluateYieldInterruptForSpellCast(GameEventSpellAbilityCast event) for (SpellAbilityStackInstance candidate : gv.getGame().getStack()) { if (candidate.getId() == targetId) { si = candidate; break; } } - if (si != null) yc.onSpellAbilityCast(si, gv); + if (si != null) yc.onSpellAbilityCast(si); } @Override From d4ac70cc8526b8f778a466b5a975653a9362ff6c Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 6 May 2026 18:47:35 +0200 Subject: [PATCH 07/36] Clean up start --- .../src/main/java/forge/gamemodes/match/YieldController.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 714a3fb6970..6d32875d85b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -7,7 +7,6 @@ import forge.game.phase.PhaseType; import forge.game.player.Player; import forge.game.player.PlayerView; -import forge.game.spellability.SpellAbility; import forge.game.spellability.SpellAbilityStackInstance; import forge.game.spellability.StackItemView; import forge.gui.interfaces.IGuiGame; @@ -20,7 +19,6 @@ import forge.player.LobbyPlayerHuman; import forge.player.PersistentAutoDecisionStore; import forge.player.PlayerControllerHuman; -import forge.util.collect.FCollection; import forge.util.collect.FCollectionView; import java.util.EnumMap; From 949837c2081eee12028ea576c124e2e27d7246b6 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 6 May 2026 20:16:55 +0200 Subject: [PATCH 08/36] Clean up start --- .../main/java/forge/gamemodes/match/YieldController.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 6d32875d85b..9ca02f05e0d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -465,8 +465,7 @@ public void onSpellAbilityCast(SpellAbilityStackInstance si) { if (!shouldEvaluateInterrupts()) return; PlayerView local = owner != null ? owner.getLocalPlayerView() : null; if (local == null) return; - Player activator = si.getActivatingPlayer(); - boolean isOpponent = activator != null && !activator.getView().equals(local); + boolean isOpponent = !si.getActivatingPlayer().getView().equals(local); if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { applyInterrupt(); @@ -607,8 +606,7 @@ private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView pl } } StackItemView subInstance = si.getSubInstance(); - if (subInstance != null && targetsPlayerOrPermanents(subInstance, player)) return true; - return false; + return subInstance != null && targetsPlayerOrPermanents(subInstance, player); } /** Recurses into sub-instances for modal spells like Farewell. */ From 3925c201b937fd16b32484ac9fdaa439bae6e504 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 6 May 2026 20:49:23 +0200 Subject: [PATCH 09/36] Clean up start --- .../src/main/java/forge/game/player/PlayerView.java | 13 +------------ .../src/forge/screens/match/views/VAvatar.java | 2 +- .../java/forge/gamemodes/match/YieldController.java | 1 - .../java/forge/gamemodes/net/NetworkGuiGame.java | 4 ++-- .../java/forge/player/PlayerControllerHuman.java | 5 ++--- 5 files changed, 6 insertions(+), 19 deletions(-) diff --git a/forge-game/src/main/java/forge/game/player/PlayerView.java b/forge-game/src/main/java/forge/game/player/PlayerView.java index c8af45eab6b..1a9302cf409 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -197,17 +197,13 @@ void updateCounters(Player p) { public boolean getIsExtraTurn() { return get(TrackableProperty.IsExtraTurn); } - public void setIsExtraTurn(final boolean val) { set(TrackableProperty.IsExtraTurn, val); } public boolean getHasLost() { - if (get(TrackableProperty.HasLost) == null) - return false; return get(TrackableProperty.HasLost); } - public void setHasLost(final boolean val) { set(TrackableProperty.HasLost, val); } @@ -215,18 +211,12 @@ public void setHasLost(final boolean val) { public boolean hasAvailableActions() { return get(TrackableProperty.HasAvailableActions); } - public void setHasAvailableActions(boolean value) { set(TrackableProperty.HasAvailableActions, value); } public int getAvatarLifeDifference() { - return (int)get(TrackableProperty.AvatarLifeDifference); - } - public boolean wasAvatarLifeChanged() { - if ((int)get(TrackableProperty.AvatarLifeDifference) == 0) - return false; - return (int)get(TrackableProperty.AvatarLifeDifference) != 0; + return get(TrackableProperty.AvatarLifeDifference); } public void setAvatarLifeDifference(final int val) { set(TrackableProperty.AvatarLifeDifference, val); @@ -235,7 +225,6 @@ public void setAvatarLifeDifference(final int val) { public int getExtraTurnCount() { return get(TrackableProperty.ExtraTurnCount); } - public void setExtraTurnCount(final int val) { set(TrackableProperty.ExtraTurnCount, val); } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java b/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java index 0cd8d1da1d6..22e63cd778a 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java @@ -125,7 +125,7 @@ public void draw(Graphics g) { float h = isHovered() ? getWidth()/16f+getHeight() : getHeight(); if (avatarAnimation != null && MatchController.instance.getGameView() != null && !MatchController.instance.getGameView().isMatchOver()) { - if (player.wasAvatarLifeChanged()) { + if (player.getAvatarLifeDifference() != 0) { avatarAnimation.start(); avatarAnimation.drawAvatar(g, image, 0, 0, w, h); } else { diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 9ca02f05e0d..4883db7d710 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -5,7 +5,6 @@ import forge.game.card.CardView; import forge.game.combat.CombatView; import forge.game.phase.PhaseType; -import forge.game.player.Player; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityStackInstance; import forge.game.spellability.StackItemView; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index d8842f6033e..b8ab5434c64 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -621,8 +621,8 @@ protected final void pushSkipPhaseToControllers(final PlayerView player, final P /** * Replace the host's persistent yield state for each controlled player - * in one atomic message: auto-yields and trigger-disabled flag from the - * AutoYieldStore, skip-phase prefs from PhaseLabel state. Per-key edits + * in one atomic message: auto-yields from theAutoYieldStore, + * skip-phase prefs from PhaseLabel state. Per-key edits * during play flow as individual YieldUpdate deltas. */ protected final void seedYieldStateOnHost() { diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index f22640a5382..637df0c3349 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1500,8 +1500,7 @@ public List chooseSpellAbilityToPlay() { // Yield check first: it's a field read, vs needsAvailableActions which does 3 synced FPref reads. if (!yieldController.isYieldActive() && needsAvailableActions()) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); - boolean result = AvailableActions.compute(getPlayer(), timeoutMs); - getPlayer().getView().setHasAvailableActions(result); + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); } // Game-thread only; no cross-thread races on the YIELD_SUPPRESS_AFTER_END flag @@ -3582,7 +3581,7 @@ private void tryAutoPassNow() { } } - /** True iff some yield consumer needs the synced wire field. */ + /** True if yield consumer needs the synced wire field. */ private boolean needsAvailableActions() { if (yieldController.getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return true; if (yieldController.getDeclineScope(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS) != DeclineScope.NEVER) return true; From f53f714f336579b9dd587ca002f37c62d3e3a561 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 7 May 2026 06:48:16 +0930 Subject: [PATCH 10/36] Route end-turn through the SetAutoPassUntilEndOfTurn envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the dedicated passPriorityUntilEndOfTurn ProtocolMethod into the existing YieldUpdate envelope so all yield-related wire traffic rides one ProtocolMethod pair, matching the V2 single-surface principle. The four call sites (CDock, GameMenu, KeyboardShortcuts, MatchScreen) now go through a YieldController.endTurn helper that mirrors the existing toggleAutoPassNoActions shape. Removing the isYieldActive early-return in tryAutoPassNow is safe because the mayAutoPass check below already encompasses it — mayAutoPass is shouldAutoYield || isAutoPassingNoActions, and shouldAutoYield is true exactly when a yield is active. The early-return only diverged from mayAutoPass in one direction: when a yield had just been activated, it blocked the OK click that the original passPriority(true) always made unconditionally. Dropping it lets the envelope path reproduce that behavior on the current InputPassPriority instead of waiting for the next cycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/control/KeyboardShortcuts.java | 2 +- .../forge/screens/match/controllers/CDock.java | 2 +- .../forge/screens/match/menus/GameMenu.java | 3 ++- .../src/forge/screens/match/MatchScreen.java | 3 ++- .../forge/gamemodes/match/YieldController.java | 6 ++++++ .../forge/gamemodes/net/ProtocolMethod.java | 1 - .../gamemodes/net/client/NetGameController.java | 5 ----- .../java/forge/gui/control/WatchLocalGame.java | 4 ---- .../java/forge/interfaces/IGameController.java | 1 - .../forge/player/PlayerControllerHuman.java | 17 ----------------- 10 files changed, 12 insertions(+), 32 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index ffc83ca074f..0f5433926a6 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -133,7 +133,7 @@ public void actionPerformed(final ActionEvent e) { public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } - matchUI.getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } }; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java index 29d37bb3894..6287745ed13 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java @@ -62,7 +62,7 @@ public VDock getView() { * End turn. */ public void endTurn() { - matchUI.getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } /** diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 3312ae35ad4..94c26ea600c 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -12,6 +12,7 @@ import com.google.common.primitives.Ints; import forge.control.KeyboardShortcuts; +import forge.gamemodes.match.YieldController; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; @@ -111,7 +112,7 @@ private SkinnedMenuItem getMenuItem_EndTurn() { } private ActionListener getEndTurnAction() { - return e -> matchUI.getGameController().passPriorityUntilEndOfTurn(); + return e -> YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } /** Sets a menu item's accelerator display from a shortcut preference. */ diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 0350d75ba0b..d24ec20160a 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -39,6 +39,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldController; import forge.gui.GuiBase; import forge.interfaces.IGameController; import forge.localinstance.properties.ForgePreferences; @@ -667,7 +668,7 @@ public boolean keyDown(int keyCode) { break; case Keys.E: //end turn on Ctrl+E on Android, E when running on desktop if (KeyInputAdapter.isCtrlKeyDown() || GuiBase.getInterface().isRunningOnDesktop()) { - getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(getGameController(), MatchController.instance.getCurrentPlayer()); return true; } break; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 4883db7d710..8e382e7a2ad 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -454,6 +454,12 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { return newVal; } + /** Activate auto-pass-until-end-of-turn for {@code local} via the unified envelope path. */ + public static void endTurn(IGameController ctrl, PlayerView local) { + if (ctrl == null || local == null) return; + ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, true)); + } + /** Eager check at REVEAL/notifyOfValue call sites — no GameEventReveal exists. */ public void maybeInterruptOnReveal() { if (!getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) return; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index 44f3146dbcc..c9adab42fd1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -88,7 +88,6 @@ public enum ProtocolMethod implements IHasForgeLog { selectButtonOk (Mode.CLIENT, Void.TYPE), selectButtonCancel (Mode.CLIENT, Void.TYPE), selectAbility (Mode.CLIENT, Void.TYPE, SpellAbilityView.class), - passPriorityUntilEndOfTurn(Mode.CLIENT, Void.TYPE), passPriority (Mode.CLIENT, Void.TYPE), nextGameDecision (Mode.CLIENT, Void.TYPE, NextGameDecision.class), getActivateDescription (Mode.CLIENT, String.class, CardView.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 4482eb98f8e..3a2f149c693 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -84,11 +84,6 @@ public void selectAbility(final SpellAbilityView sa) { send(ProtocolMethod.selectAbility, sa); } - @Override - public void passPriorityUntilEndOfTurn() { - send(ProtocolMethod.passPriorityUntilEndOfTurn); - } - @Override public void passPriority() { send(ProtocolMethod.passPriority); diff --git a/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java b/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java index e3925482f3f..27a0e54eccf 100644 --- a/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java +++ b/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java @@ -62,10 +62,6 @@ public void selectButtonCancel() { public void passPriority() { } - @Override - public void passPriorityUntilEndOfTurn() { - } - @Override public void useMana(final byte mana) { } diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 992e35c888c..f1d579d541b 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -53,7 +53,6 @@ public interface IGameController { void requestResync(); void passPriority(); - void passPriorityUntilEndOfTurn(); // Auto-yield preferences boolean shouldAutoYield(String key); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 637df0c3349..c131ddda844 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -2398,25 +2398,9 @@ public void selectButtonCancel() { @Override public void passPriority() { - passPriority(false); - } - - @Override - public void passPriorityUntilEndOfTurn() { - passPriority(true); - } - - private void passPriority(final boolean passUntilEndOfTurn) { final Input inp = inputProxy.getInput(); if (inp instanceof InputPassPriority) { - if (passUntilEndOfTurn) { - autoPassUntilEndOfTurn(); - } inp.selectButtonOK(); - } else { - FThreads.invokeInEdtNowOrLater(() -> { - // getGui().message("Cannot pass priority at this time."); - }); } } @@ -3571,7 +3555,6 @@ public void setYieldStringPref(final FPref pref, final String value) { * Same compute gating as {@link #chooseSpellAbilityToPlay} so the actions field is fresh. */ private void tryAutoPassNow() { if (!(inputQueue.getInput() instanceof InputPassPriority)) return; - if (yieldController.isYieldActive()) return; if (needsAvailableActions()) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); From b3bf969cbb39478ab831cbbc41ac865c8485725e Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 7 May 2026 07:03:03 +0930 Subject: [PATCH 11/36] Seed proxy with full pref set; drop redundant local override stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot building (snapshotBoolPrefs / snapshotStringPrefs) was returning a copy of the local override map, which is empty at game start because the override map only fills when the user toggles a setting mid-game. The host's proxy of a remote player would receive an empty seed, then fall through to the host's FModel for every read — silently substituting the host's preferences for the client's. Defaults-only players didn't notice because the two default-on interrupts matched between unconfigured host and client; anyone who customized their settings would see them ignored until they re-toggled each pref mid-match. Fix: enumerate the synced yield FPrefs (SYNCED_BOOL_PREFS / SYNCED_STRING_PREFS) and read each effective value from FModel when building the snapshot, so the proxy is seeded with the client's actual values from turn 1. This makes the override map's role coherent: populated only on the host's proxy of a remote player, via applyClientSeed and the SetYieldBoolPref/SetYieldStringPref envelopes. Local controllers (host's own PCH and client's NetGameController) always read through FModel fallback — the dialog already writes there, so the parallel local store was dead weight. Drop the yieldController.setBoolPref/setStringPref calls from PCH.setYieldBoolPref/StringPref and the equivalent in NetGameController; PCH.setYieldBoolPref still fires tryAutoPassNow because toggling APINA may flip mayAutoPass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gamemodes/match/YieldController.java | 32 +++++++++++++++++-- .../net/client/NetGameController.java | 2 -- .../forge/player/PlayerControllerHuman.java | 4 +-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 8e382e7a2ad..6a0da0038f3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -41,6 +41,25 @@ */ public class YieldController { + /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. */ + private static final EnumSet SYNCED_BOOL_PREFS = EnumSet.of( + FPref.YIELD_INTERRUPT_ON_ATTACKERS, + FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, + FPref.YIELD_INTERRUPT_ON_TARGETING, + FPref.YIELD_INTERRUPT_ON_TRIGGERS, + FPref.YIELD_INTERRUPT_ON_REVEAL, + FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, + FPref.YIELD_AUTO_PASS_NO_ACTIONS, + FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, + FPref.YIELD_SKIP_PHASE_DELAY, + FPref.YIELD_SKIP_RESOLVE_DELAY, + FPref.YIELD_SUPPRESS_ON_OWN_TURN, + FPref.YIELD_SUPPRESS_AFTER_END); + private static final EnumSet SYNCED_STRING_PREFS = EnumSet.of( + FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, + FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + private final PlayerControllerHuman owner; private boolean autoPassUntilEOT; @@ -57,7 +76,7 @@ public class YieldController { private final AutoYieldStore localStore = new AutoYieldStore(); private final Map> skipPhases = new HashMap<>(); - /** Override wins, FModel is fallback. Synced per-PCH so each client's prefs govern their proxy. */ + /** Populated only on the host's proxy of a remote player (via {@link #applyClientSeed} and {@link YieldUpdate.SetYieldBoolPref}/{@link YieldUpdate.SetYieldStringPref} envelopes); local controllers always defer to FModel. Override wins, FModel is fallback. */ private final EnumMap boolPrefOverrides = new EnumMap<>(FPref.class); private final EnumMap stringPrefOverrides = new EnumMap<>(FPref.class); @@ -125,11 +144,18 @@ public DeclineScope getDeclineScope(FPref pref) { return DeclineScope.fromPref(getStringPref(pref)); } + /** Read effective values from FModel for every synced yield FPref so the host's proxy can be seeded with the full set, not just prefs the user has touched this session. */ public Map snapshotBoolPrefs() { - return new EnumMap<>(boolPrefOverrides); + ForgePreferences prefs = FModel.getPreferences(); + EnumMap out = new EnumMap<>(FPref.class); + for (FPref pref : SYNCED_BOOL_PREFS) out.put(pref, prefs.getPrefBoolean(pref)); + return out; } public Map snapshotStringPrefs() { - return new EnumMap<>(stringPrefOverrides); + ForgePreferences prefs = FModel.getPreferences(); + EnumMap out = new EnumMap<>(FPref.class); + for (FPref pref : SYNCED_STRING_PREFS) out.put(pref, prefs.getPref(pref)); + return out; } // setMarker/clearMarker are mutated from EDT (right-click), Netty (wire receive), and game thread. diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 3a2f149c693..c7b3b8fccee 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -207,13 +207,11 @@ public void seedYieldStateOnHost(Map> skipPhases) @Override public void setYieldBoolPref(final FPref pref, final boolean value) { - yieldController.setBoolPref(pref, value); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldBoolPref(pref, value)); } @Override public void setYieldStringPref(final FPref pref, final String value) { - yieldController.setStringPref(pref, value); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldStringPref(pref, value)); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index c131ddda844..e65da095725 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3542,13 +3542,13 @@ public void applyYieldUpdate(final YieldUpdate update) { @Override public void setYieldBoolPref(final FPref pref, final boolean value) { - yieldController.setBoolPref(pref, value); + // Dialog already wrote to FModel; APINA toggles may flip mayAutoPass tryAutoPassNow(); } @Override public void setYieldStringPref(final FPref pref, final String value) { - yieldController.setStringPref(pref, value); + // Dialog already wrote to FModel; nothing else to do for the local player } /** Re-evaluate mayAutoPass at the current prompt; click OK if it would now fire. From d50617b5a66dbadd80fd8fcfde11803370a36837 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 7 May 2026 20:28:43 +0930 Subject: [PATCH 12/36] Skip AvailableActions compute in tryAutoPassNow when yielding. Addresses review comment on #10606. When a yield is active, mayAutoPass() short-circuits via shouldAutoYield() and never consults the available-actions field, so computing it is wasted work. Match the gate already used by chooseSpellAbilityToPlay. Co-Authored-By: Claude Opus 4.7 (1M context) --- forge-gui/src/main/java/forge/player/PlayerControllerHuman.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index e65da095725..9abdf71db1a 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3555,7 +3555,7 @@ public void setYieldStringPref(final FPref pref, final String value) { * Same compute gating as {@link #chooseSpellAbilityToPlay} so the actions field is fresh. */ private void tryAutoPassNow() { if (!(inputQueue.getInput() instanceof InputPassPriority)) return; - if (needsAvailableActions()) { + if (!yieldController.isYieldActive() && needsAvailableActions()) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); } From 42492626ac65b82df2632681b8df40e0b046d8b0 Mon Sep 17 00:00:00 2001 From: TRT <> Date: Thu, 7 May 2026 13:41:25 +0200 Subject: [PATCH 13/36] Clean up --- docs/advanced-yield-options.md | 2 +- .../src/main/java/forge/ai/AiCostDecision.java | 3 +-- .../src/main/java/forge/ai/AvailableActions.java | 14 +++----------- forge-ai/src/main/java/forge/ai/ComputerUtil.java | 4 +--- forge-ai/src/main/java/forge/ai/SpecialCardAi.java | 14 +++++++------- .../main/java/forge/ai/ability/ChangeZoneAi.java | 4 ++-- .../main/java/forge/ai/ability/ChooseCardAi.java | 3 +-- .../src/main/java/forge/game/card/CardLists.java | 1 + 8 files changed, 17 insertions(+), 28 deletions(-) diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index c9339383266..7a4e65528a3 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -23,7 +23,7 @@ These features are highly configurable through the **Yield Settings** dialog, an ## How to Access -- **Desktop:** The Auto-Pass icon and a yield-settings cog appear on the dock panel above the prompt area. Click the cog, open the Game menu > **Yield Settings**, or press Ctrl+Y to bring up the full settings dialog. Click the Auto-Pass icon (or press F2) to toggle Auto-Pass — its background lights up gold while active. +- **Desktop:** The Auto-Pass icon and a yield-settings cog appear on the dock panel above the prompt area. Click the cog, open the Game menu > **Yield Settings**, or press Ctrl+Y to bring up the full settings dialog. - **Mobile:** open the in-match Game menu > **Yield Options**. ## Auto-Pass diff --git a/forge-ai/src/main/java/forge/ai/AiCostDecision.java b/forge-ai/src/main/java/forge/ai/AiCostDecision.java index 7f694c1b51a..b8fd3532faa 100644 --- a/forge-ai/src/main/java/forge/ai/AiCostDecision.java +++ b/forge-ai/src/main/java/forge/ai/AiCostDecision.java @@ -199,8 +199,7 @@ public PaymentDecision visit(CostExile cost) { CardCollection valid = CardLists.getValidCards(player.getGame().getCardsIn(cost.getFrom().get(0)), typeCleaned, player, source, ability); CardCollection chosen = new CardCollection(); - CardLists.sortByCmcDesc(valid); - Collections.reverse(valid); + valid.sort(CardLists.CmcComparator); int totalCMC = 0; for (Card card : valid) { diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index 0539743759e..71998548943 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -1,21 +1,18 @@ package forge.ai; import forge.game.card.Card; +import forge.game.card.CardLists; import forge.game.player.Player; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; import org.tinylog.Logger; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; +import java.util.stream.Collectors; // Heuristic: does the player have any playable action this priority window? // Bounded by timeoutMs; returns true on expiry (false-positive — player is prompted). public final class AvailableActions { - private static final Comparator BY_CMC_ASC = Comparator.comparingInt(Card::getCMC); - private AvailableActions() {} public static boolean compute(Player player, long timeoutMs) { @@ -58,12 +55,7 @@ public static boolean compute(Player player, long timeoutMs) { // Sort cheap cards first so cheap-to-validate matches early-exit private static Iterable sortedCardsIn(Player player, ZoneType zone) { - Iterable cards = player.getCardsIn(zone); - List copy = new ArrayList<>(); - cards.forEach(copy::add); - if (copy.size() < 2) return copy; - copy.sort(BY_CMC_ASC); - return copy; + return player.getCardsIn(zone).stream().sorted(CardLists.CmcComparator).collect(Collectors.toList()); } private static boolean canAfford(SpellAbility sa, Player player) { diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtil.java b/forge-ai/src/main/java/forge/ai/ComputerUtil.java index 77189dfd752..2120da9b47e 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtil.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtil.java @@ -590,9 +590,7 @@ public static CardCollection chooseCollectEvidence(final Player ai, CostCollectE if (CardLists.getTotalCMC(typeList) < amount) return null; - // FIXME: This is suboptimal, maybe implement a single comparator that'll take care of all of this? - CardLists.sortByCmcDesc(typeList); - Collections.reverse(typeList); + typeList.sort(CardLists.CmcComparator); // TODO AI needs some improvements here // What's the best way to choose evidence to collect? diff --git a/forge-ai/src/main/java/forge/ai/SpecialCardAi.java b/forge-ai/src/main/java/forge/ai/SpecialCardAi.java index 753d060c565..756c7cbdaef 100644 --- a/forge-ai/src/main/java/forge/ai/SpecialCardAi.java +++ b/forge-ai/src/main/java/forge/ai/SpecialCardAi.java @@ -1680,12 +1680,13 @@ public static AiAbilityDecision consider(final Player ai, final SpellAbility sa) return copy.getNetToughness() > 0; }) ); - CardLists.sortByCmcDesc(creaturesToGet); if (creaturesToGet.isEmpty()) { return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi); } + CardLists.sortByCmcDesc(creaturesToGet); + // pick the best creature that will stay on the battlefield Card best = creaturesToGet.getFirst(); for (Card c : creaturesToGet) { @@ -1742,7 +1743,7 @@ public static Card considerDiscardTarget(final Player ai) { // Cards in hand that are below the max CMC affordable by the AI CardCollection belowMaxCMC = CardLists.filter(creatsInHand, CardPredicates.lessCMC(numManaSrcs - 1)); - belowMaxCMC.sort(Collections.reverseOrder(CardLists.CmcComparatorInv)); + belowMaxCMC.sort(CardLists.CmcComparator); // Cards in hand that are above the max CMC affordable by the AI CardCollection aboveMaxCMC = CardLists.filter(creatsInHand, CardPredicates.greaterCMC(numManaSrcs + 1)); @@ -1788,7 +1789,7 @@ public static Card considerDiscardTarget(final Player ai) { } public static Card considerCardToGet(final Player ai, final SpellAbility sa) { - CardCollectionView creatsInLib = CardLists.filter(ai.getCardsIn(ZoneType.Library), CardPredicates.CREATURES); + CardCollection creatsInLib = CardLists.filter(ai.getCardsIn(ZoneType.Library), CardPredicates.CREATURES); if (creatsInLib.isEmpty()) { return null; } @@ -1805,13 +1806,12 @@ public static Card considerCardToGet(final Player ai, final SpellAbility sa) { } atTargetCMCInLib.sort(CardLists.CmcComparatorInv); - Card bestInLib = atTargetCMCInLib != null ? atTargetCMCInLib.getFirst() : null; + Card bestInLib = atTargetCMCInLib.getFirst(); if (bestInLib == null && ComputerUtil.isPlayingReanimator(ai)) { // For Reanimator, we don't mind grabbing the biggest thing possible to recycle it again with SotF later. - CardCollection creatsInLibByCMC = new CardCollection(creatsInLib); - creatsInLibByCMC.sort(CardLists.CmcComparatorInv); - return creatsInLibByCMC.getFirst(); + creatsInLib.sort(CardLists.CmcComparatorInv); + return creatsInLib.getFirst(); } return bestInLib; diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index 6dc6a606b5e..1b173fb1ee2 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1712,7 +1712,7 @@ private AiAbilityDecision doSacAndReturnFromGraveLogic(final Player ai, final Sp String definedSac = StringUtils.split(source.getSVar("AIPreference"), "$")[1]; CardCollection listToSac = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), definedSac, ai, source, sa); - listToSac.sort(Collections.reverseOrder(CardLists.CmcComparatorInv)); + listToSac.sort(CardLists.CmcComparator); CardCollection listToRet = CardLists.filter(ai.getCardsIn(ZoneType.Graveyard), CardPredicates.CREATURES); listToRet.sort(CardLists.CmcComparatorInv); @@ -1749,7 +1749,7 @@ private AiAbilityDecision doSacAndUpgradeLogic(final Player ai, final SpellAbili boolean anyCMC = !definedGoal.contains(".cmc"); CardCollection listToSac = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), definedSac, ai, source, sa); - listToSac.sort(!sacWorst ? CardLists.CmcComparatorInv : Collections.reverseOrder(CardLists.CmcComparatorInv)); + listToSac.sort(!sacWorst ? CardLists.CmcComparatorInv : CardLists.CmcComparator); for (Card sacCandidate : listToSac) { int sacCMC = sacCandidate.getCMC(); diff --git a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java index b8f4539eee2..ca3adcecd6e 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java @@ -214,8 +214,7 @@ public Card chooseSingleCard(final Player ai, final SpellAbility sa, Iterable in, final in public static final Comparator ToughnessComparator = Comparator.comparingInt(Card::getNetToughness); public static final Comparator ToughnessComparatorInv = Comparator.comparingInt(Card::getNetToughness).reversed(); public static final Comparator PowerComparator = Comparator.comparingInt(Card::getNetCombatDamage); + public static final Comparator CmcComparator = Comparator.comparingInt(Card::getCMC); public static final Comparator CmcComparatorInv = Comparator.comparingInt(Card::getCMC).reversed(); public static final Comparator TextLenComparator = Comparator.comparingInt(a -> a.getView().getText().length()); From 6ee945219a25126722833fbc41782832ce32b739 Mon Sep 17 00:00:00 2001 From: TRT <> Date: Thu, 7 May 2026 13:44:12 +0200 Subject: [PATCH 14/36] Fix import --- forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java | 1 - 1 file changed, 1 deletion(-) diff --git a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java index ca3adcecd6e..6ada7a5c8de 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java @@ -15,7 +15,6 @@ import forge.util.Aggregates; import forge.util.IterableUtil; -import java.util.Collections; import java.util.List; import java.util.Map; From 40499898c55cb65dfc1c62595d2697b3e55522ca Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Thu, 7 May 2026 20:03:56 +0200 Subject: [PATCH 15/36] Minor clean up --- .../java/forge/gamemodes/net/NetworkGuiGame.java | 2 +- .../gui/control/FControlGameEventHandler.java | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index b8ab5434c64..f0335bf6922 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -621,7 +621,7 @@ protected final void pushSkipPhaseToControllers(final PlayerView player, final P /** * Replace the host's persistent yield state for each controlled player - * in one atomic message: auto-yields from theAutoYieldStore, + * in one atomic message: auto-yields from the AutoYieldStore, * skip-phase prefs from PhaseLabel state. Per-key edits * during play flow as individual YieldUpdate deltas. */ diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index 731c5ff5e7f..04b12b0758c 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -281,17 +281,17 @@ public Void visit(final GameEventSpellAbilityCast event) { private void evaluateYieldInterruptForSpellCast(GameEventSpellAbilityCast event) { if (humanController == null) return; YieldController yc = humanController.getYieldController(); - if (yc == null || !yc.isYieldActive()) return; + if (!yc.isYieldActive()) return; GameView gv = matchController.getGameView(); if (gv == null || gv.getGame() == null) return; // Look up the actual SpellAbilityStackInstance by id (host-side; client gv.getGame() is null). - int targetId = event.si() != null ? event.si().getId() : -1; - if (targetId < 0) return; - SpellAbilityStackInstance si = null; + int targetId = event.si().getId(); for (SpellAbilityStackInstance candidate : gv.getGame().getStack()) { - if (candidate.getId() == targetId) { si = candidate; break; } + if (candidate.getId() == targetId) { + yc.onSpellAbilityCast(candidate); + return; + } } - if (si != null) yc.onSpellAbilityCast(si); } @Override @@ -374,7 +374,7 @@ public Void visit(final GameEventBlockersDeclared event) { public Void visit(final GameEventAttackersDeclared event) { if (humanController != null) { YieldController yc = humanController.getYieldController(); - if (yc != null && yc.isYieldActive()) { + if (yc.isYieldActive()) { GameView gv = matchController.getGameView(); if (gv != null && gv.getCombat() != null) yc.onAttackersDeclared(gv.getCombat()); } From 9fc503439bceda7445ed7ff9e04787bc4154b955 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 8 May 2026 08:11:07 +0930 Subject: [PATCH 16/36] User Guide: split Auto-Yield section; add sidebar entry Replace the single Auto-Yield section with "Auto-Pass and Yield Options" (concept summary plus Auto-Pass No Actions, End Turn, yield markers, and stack-yield) and "Auto-Yield and Trigger Decisions" (the four lifetime scopes for sticky stack decisions). Sidebar gains an entry for advanced-yield-options.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/User-Guide.md | 33 ++++++++++++++++++++++++++------- docs/_sidebar.md | 1 + 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/User-Guide.md b/docs/User-Guide.md index d9949a0a834..a2af1533e61 100644 --- a/docs/User-Guide.md +++ b/docs/User-Guide.md @@ -16,7 +16,8 @@ - [Easier creature type selection](#easier-creature-type-selection) - [Auto-Target](#auto-target) - [Auto-Pay](#auto-pay) - - [Auto-Yield](#auto-yield) + - [Auto-Pass and Yield Options](#auto-pass-and-yield-options) + - [Auto-Yield and Trigger Decisions](#auto-yield-and-trigger-decisions) - [Shift Key helper](#shift-key-helper) - [Full Control](#full-control) - [Repeatable Sequences (Macros)](#repeatable-sequences-macros) @@ -189,16 +190,34 @@ When paying mana costs, you can press Enter/Spacebar or click the Auto button in - You can still manually pay the cost by clicking mana sources in play (e.g. lands) or clicking symbols in your mana pool, which might be a good idea if you want to save specific mana sources for a later play that turn. - you'll still be prompted when paying Sunburst or cards that care what colors are spent to cast it (ex. Firespout). -## Auto-Yield -- When a spell or an ability appears on the stack and it says "(OPTIONAL)" you can right-click it to decide if you want to always accept or to decline it. +## Auto-Pass and Yield Options +> [!NOTE] +> For more information and configuration options — including interrupt conditions, automatic yield suggestions, and speed settings — see [Advanced Yield Options](advanced-yield-options.md). + +**Yielding** lets you hand priority to Forge so it passes on your behalf instead of you needing to click through every priority pass. This helps you get to your desired phase of action quickly. + +Forge offers several yield options depending on how long you want to skip prompts: + +- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or F2 hoktey, or on **Mobile** from the in-match Game menu. +- **End Turn** — auto-pass through the rest of the current turn, bypassing any phase stops. Triggered by the End Turn dock button. +- **Yield markers** — auto-pass until a specific phase is reached. Right-click (or long-press) a phase indicator to set one; a fast-forward symbol marks the active cell. Each (player, phase) cell is independent, so in multiplayer you can yield to a specific opponent's end step. +- **Yield to stack / Resolve entire stack** — auto-pass while the stack resolves. Right-click a stack item to choose: **Yield to stack** auto-passes until the stack empties or an interrupt fires (for example, an opponent casts another spell); **Resolve entire stack** keeps auto-passing until the whole stack is empty even if opponents cast more spells. + +By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. + +## Auto-Yield and Trigger Decisions +- When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept it (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. - It is possible to specify the granularity level for auto-yields: the difference is that, for example, when choosing per ability if you auto-yield to Hellrider's triggered ability once, all triggers from other Hellrider cards will be automatically yielded to as well. When choosing per card, you will need to auto-yield to each Hellrider separately. +- The granularity and lifetime of these decisions are controlled by the **Auto Yield/Trigger Mode** setting under Settings → Preferences: - Note that in when auto-yielding per ability, yields will NOT be automatically cleared between games in a match, which should speed the game up. When auto-yielding per card, yields WILL be automatically cleared between games because they are dependent on card IDs which change from game to game, thus you will need to auto-yield to each card again in each game of the match. + - **Per Card (Each Game)** — decisions are tied to a specific card instance and cleared at the end of each game. You'll need to set them again in the next game of the match. (For example, auto-yielding one Hellrider does not affect another copy of Hellrider.) + - **Per Ability (Each Match)** — decisions apply to every copy of the ability and persist across games within the current match, then clear when you start a new match. + - **Per Ability (Each Session)** — decisions persist across matches until you close Forge. + - **Per Ability (Each Install)** — decisions are saved to disk and persist across Forge restarts. -- Pressing "End Turn" skips your attack phase and doesn't get cancelled automatically if a spell or ability is put on the stack. You'll still be given a chance to declare blockers if your opponent attacks, but after that the rest of your opponent's turn will then progress without you receiving priority. +- Pick a longer-lived scope when you want recurring triggers (e.g. routine ETBs, upkeep optional triggers) to stay yielded across many games; pick a shorter scope when you want a clean slate each game. - To alleviate pressing this accidentally, as long as you're passing this way, you'll be able to press Escape or the Cancel button to be given the chance to act again. Phases with stops and spells/abilities resolving will be given a slight delay to allow you to see what's going on. +- The current list of active auto-yields and Always Yes / Always No trigger decisions is visible from Game → Auto-Yields and Triggers, where individual entries can be cleared. ## Shift Key helper * When you mouse over a flip, transform or Morph (controlled by you) card in battlefield, hold SHIFT to see other state of that card at the side panel that displays card picture and details. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 105088fd83a..100927cf60c 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -6,6 +6,7 @@ - [AI](ai.md) - [Network Play](network-play.md) - [Advanced search](Advanced-Search.md) + - [Advanced Yield Options](advanced-yield-options.md) - Adventure Mode From 4b3f169de6cf94f9cb9d057ced923c1373d3126b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 8 May 2026 17:44:37 +0930 Subject: [PATCH 17/36] Don't let APINA skip declare-attackers; treat forced prompt as yield interrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhaseHandler.declareAttackersTurnBasedAction already gates on CombatUtil.canAttack, so by the time declareAttackers is invoked the player has at least one legal attacker — the prompt itself is the available action. Switch the gate from mayAutoPass to shouldAutoYield so APINA never skips, while explicit yields keep their legacy "skip when not-attacking is legal" behavior. When a must-attack/goaded creature forces the prompt during a yield, clear the yield via applyInterrupt() to match every other interrupt classifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/forge/player/PlayerControllerHuman.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 318545cb2d5..c6374a704ae 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1470,11 +1470,15 @@ public CardCollectionView tuckCardsViaMulligan(CardCollectionView hand, int card @Override public void declareAttackers(final Player attackingPlayer, final Combat combat) { - if (mayAutoPass()) { + // CombatUtil.canAttack ensures we only get here when the player has a legal attacker + // so APINA must never skip - the invocation itself is the available action. User + // initiated yields (pass until end of turn) still skip when not-attacking is legal. + if (yieldController.shouldAutoYield()) { if (CombatUtil.validateAttackers(combat)) { - return; // don't prompt to declare attackers if user chose to - // end the turn and not attacking is legal + return; } + // if forced to prompt (must-attack/goad) clear the yield like any other interrupt + yieldController.applyInterrupt(); } // This input should not modify combat object itself, but should return user choice From 10c706c31786e4c00197cb5f9a41e43ffd5692a0 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 8 May 2026 18:24:34 +0930 Subject: [PATCH 18/36] Unify yield-pref wire envelope; gate APINA recompute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse SetYieldBoolPref/SetYieldStringPref into SetYieldPref. PreferencesStore is already String-typed, so the type split duplicated infrastructure with no behavior gain. Wire field is String; callers wrap with String.valueOf. Chose call-site conversion over the host-side Object→String approach to keep the wire/storage types aligned and avoid Object in a record. The substantive consolidation is the same either way. setYieldPref now skips tryAutoPassNow unless the changed pref is YIELD_AUTO_PASS_NO_ACTIONS — that's the only pref whose toggle can flip mayAutoPass for a sitting prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/screens/match/VYieldSettings.java | 6 +- .../screens/match/views/VYieldOptions.java | 6 +- .../gamemodes/match/YieldController.java | 57 +++++++------------ .../gamemodes/match/YieldStateSnapshot.java | 3 +- .../forge/gamemodes/match/YieldUpdate.java | 8 +-- .../net/client/NetGameController.java | 9 +-- .../forge/interfaces/IGameController.java | 5 +- .../forge/player/PlayerControllerHuman.java | 11 +--- 8 files changed, 37 insertions(+), 68 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java index e8385139435..5225eb22fa7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java @@ -147,7 +147,7 @@ private int addCheckbox(int x, int y, int w, String label, FPref pref, ForgePref prefs.save(); IGameController controller = matchUI == null ? null : matchUI.getGameController(); if (controller != null) { - controller.setYieldBoolPref(pref, value); + controller.setYieldPref(pref, String.valueOf(value)); } }); add(cb, x, y, w, ROW_HEIGHT); @@ -186,7 +186,7 @@ private int addLabelWithDropdown(int x, int y, int w, String label, prefs.setPref(scopePref, value); prefs.save(); IGameController controller = matchUI == null ? null : matchUI.getGameController(); - if (controller != null) controller.setYieldStringPref(scopePref, value); + if (controller != null) controller.setYieldPref(scopePref, value); } }); add(combo, x + w - DROPDOWN_WIDTH, y, DROPDOWN_WIDTH, ROW_HEIGHT); @@ -225,7 +225,7 @@ private void save() { prefs.setPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); prefs.save(); IGameController controller = matchUI == null ? null : matchUI.getGameController(); - if (controller != null) controller.setYieldStringPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); + if (controller != null) controller.setYieldPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); } @Override public void insertUpdate(DocumentEvent e) { save(); } @Override public void removeUpdate(DocumentEvent e) { save(); } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java index c530044ef16..a05a8e48a39 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java @@ -267,7 +267,7 @@ private static int scopeIndex(DeclineScope[] options, String prefValue) { private static void persistBool(IGameController ctrl, FPref pref, boolean value) { FModel.getPreferences().setPref(pref, value); FModel.getPreferences().save(); - if (ctrl != null) ctrl.setYieldBoolPref(pref, value); + if (ctrl != null) ctrl.setYieldPref(pref, String.valueOf(value)); } private static void persistScope(IGameController ctrl, FPref pref, DeclineScope[] options, int index) { @@ -275,13 +275,13 @@ private static void persistScope(IGameController ctrl, FPref pref, DeclineScope[ String value = options[index].name(); FModel.getPreferences().setPref(pref, value); FModel.getPreferences().save(); - if (ctrl != null) ctrl.setYieldStringPref(pref, value); + if (ctrl != null) ctrl.setYieldPref(pref, value); } private static void persistString(IGameController ctrl, FPref pref, String value) { FModel.getPreferences().setPref(pref, value); FModel.getPreferences().save(); - if (ctrl != null) ctrl.setYieldStringPref(pref, value); + if (ctrl != null) ctrl.setYieldPref(pref, value); } @Override diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 6a0da0038f3..d62550f23dd 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -41,8 +41,8 @@ */ public class YieldController { - /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. */ - private static final EnumSet SYNCED_BOOL_PREFS = EnumSet.of( + /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. Stored String-typed (see {@link forge.localinstance.properties.PreferencesStore}); consumers parse via {@link #getBoolPref}/{@link #getStringPref} according to the pref's expected type. */ + private static final EnumSet SYNCED_PREFS = EnumSet.of( FPref.YIELD_INTERRUPT_ON_ATTACKERS, FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, FPref.YIELD_INTERRUPT_ON_TARGETING, @@ -54,8 +54,7 @@ public class YieldController { FPref.YIELD_SKIP_PHASE_DELAY, FPref.YIELD_SKIP_RESOLVE_DELAY, FPref.YIELD_SUPPRESS_ON_OWN_TURN, - FPref.YIELD_SUPPRESS_AFTER_END); - private static final EnumSet SYNCED_STRING_PREFS = EnumSet.of( + FPref.YIELD_SUPPRESS_AFTER_END, FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); @@ -76,9 +75,8 @@ public class YieldController { private final AutoYieldStore localStore = new AutoYieldStore(); private final Map> skipPhases = new HashMap<>(); - /** Populated only on the host's proxy of a remote player (via {@link #applyClientSeed} and {@link YieldUpdate.SetYieldBoolPref}/{@link YieldUpdate.SetYieldStringPref} envelopes); local controllers always defer to FModel. Override wins, FModel is fallback. */ - private final EnumMap boolPrefOverrides = new EnumMap<>(FPref.class); - private final EnumMap stringPrefOverrides = new EnumMap<>(FPref.class); + /** Populated only on the host's proxy of a remote player (via {@link #applyClientSeed} and {@link YieldUpdate.SetYieldPref} envelopes); local controllers always defer to FModel. Override wins, FModel is fallback. */ + private final EnumMap prefOverrides = new EnumMap<>(FPref.class); public YieldController(PlayerControllerHuman owner) { this.owner = owner; @@ -124,20 +122,15 @@ public synchronized void setAutoPassUntilEndOfTurn(boolean active) { this.autoPassUntilEOT = active; } - public synchronized boolean getBoolPref(FPref pref) { - Boolean override = boolPrefOverrides.get(pref); - return override != null ? override : FModel.getPreferences().getPrefBoolean(pref); - } - public synchronized void setBoolPref(FPref pref, boolean value) { - boolPrefOverrides.put(pref, value); - } - public synchronized String getStringPref(FPref pref) { - String override = stringPrefOverrides.get(pref); + String override = prefOverrides.get(pref); return override != null ? override : FModel.getPreferences().getPref(pref); } - public synchronized void setStringPref(FPref pref, String value) { - stringPrefOverrides.put(pref, value); + public boolean getBoolPref(FPref pref) { + return Boolean.parseBoolean(getStringPref(pref)); + } + public synchronized void setPref(FPref pref, String value) { + prefOverrides.put(pref, value); } public DeclineScope getDeclineScope(FPref pref) { @@ -145,16 +138,10 @@ public DeclineScope getDeclineScope(FPref pref) { } /** Read effective values from FModel for every synced yield FPref so the host's proxy can be seeded with the full set, not just prefs the user has touched this session. */ - public Map snapshotBoolPrefs() { - ForgePreferences prefs = FModel.getPreferences(); - EnumMap out = new EnumMap<>(FPref.class); - for (FPref pref : SYNCED_BOOL_PREFS) out.put(pref, prefs.getPrefBoolean(pref)); - return out; - } - public Map snapshotStringPrefs() { + public Map snapshotPrefs() { ForgePreferences prefs = FModel.getPreferences(); EnumMap out = new EnumMap<>(FPref.class); - for (FPref pref : SYNCED_STRING_PREFS) out.put(pref, prefs.getPref(pref)); + for (FPref pref : SYNCED_PREFS) out.put(pref, prefs.getPref(pref)); return out; } @@ -302,7 +289,7 @@ public synchronized void resetForNewGame() { wasAutoPassingLastTick = false; yieldJustEndedFlag = false; autoPassInterrupted = false; - // boolPrefOverrides / stringPrefOverrides intentionally kept — per-match, not per-game + // prefOverrides intentionally kept — per-match, not per-game } public boolean getDisableAutoYields() { @@ -372,7 +359,7 @@ public YieldStateSnapshot buildClientSnapshot(Map cardYields, abilityYields, cardTriggers, abilityTriggers, getDisableAutoYields(), getDisableAutoTriggers(), - skipPhases, snapshotBoolPrefs(), snapshotStringPrefs()); + skipPhases, snapshotPrefs()); } /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ @@ -390,10 +377,8 @@ public void applyClientSeed(YieldStateSnapshot snap) { localStore.setTriggerDecisionsDisabled(snap.autoTriggersDisabled()); skipPhases.clear(); skipPhases.putAll(snap.skipPhases()); - boolPrefOverrides.clear(); - if (snap.boolPrefOverrides() != null) boolPrefOverrides.putAll(snap.boolPrefOverrides()); - stringPrefOverrides.clear(); - if (snap.stringPrefOverrides() != null) stringPrefOverrides.putAll(snap.stringPrefOverrides()); + prefOverrides.clear(); + if (snap.prefOverrides() != null) prefOverrides.putAll(snap.prefOverrides()); } /** @@ -424,10 +409,8 @@ public boolean apply(YieldUpdate update) { setDisableAutoTriggers(u.disabled()); } else if (update instanceof YieldUpdate.SkipPhase u) { setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); - } else if (update instanceof YieldUpdate.SetYieldBoolPref u) { - setBoolPref(u.pref(), u.value()); - } else if (update instanceof YieldUpdate.SetYieldStringPref u) { - setStringPref(u.pref(), u.value()); + } else if (update instanceof YieldUpdate.SetYieldPref u) { + setPref(u.pref(), u.value()); } else if (update instanceof YieldUpdate.SeedFromClient u) { applyClientSeed(u.snapshot()); } @@ -476,7 +459,7 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { boolean newVal = !prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); prefs.setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); prefs.save(); - ctrl.setYieldBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + ctrl.setYieldPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, String.valueOf(newVal)); return newVal; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java index 877df9ae844..9a927dc9587 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java @@ -26,6 +26,5 @@ public record YieldStateSnapshot( boolean autoYieldsDisabled, boolean autoTriggersDisabled, Map> skipPhases, - Map boolPrefOverrides, - Map stringPrefOverrides + Map prefOverrides ) implements Serializable {} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index 65b4f0eb21f..d3d2df84295 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -23,8 +23,7 @@ public sealed interface YieldUpdate extends Serializable YieldUpdate.SetDisableYields, YieldUpdate.SetDisableTriggers, YieldUpdate.SkipPhase, - YieldUpdate.SetYieldBoolPref, - YieldUpdate.SetYieldStringPref, + YieldUpdate.SetYieldPref, YieldUpdate.SeedFromClient { /** {@code atOrPastAtClick}: priority was at-or-past target on owner's turn when the user clicked — computed by the UI so client cache and host PCH initialize identically. */ @@ -49,9 +48,8 @@ record SetDisableTriggers(boolean disabled) implements YieldUpdate {} record SkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} - record SetYieldBoolPref(FPref pref, boolean value) implements YieldUpdate {} - - record SetYieldStringPref(FPref pref, String value) implements YieldUpdate {} + /** Pref values are stored String-typed in {@link forge.localinstance.properties.PreferencesStore}; callers wrap booleans with {@code String.valueOf} at the call site. */ + record SetYieldPref(FPref pref, String value) implements YieldUpdate {} record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index c7b3b8fccee..5814e13a86f 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -206,13 +206,8 @@ public void seedYieldStateOnHost(Map> skipPhases) } @Override - public void setYieldBoolPref(final FPref pref, final boolean value) { - send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldBoolPref(pref, value)); - } - - @Override - public void setYieldStringPref(final FPref pref, final String value) { - send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldStringPref(pref, value)); + public void setYieldPref(final FPref pref, final String value) { + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldPref(pref, value)); } private IMacroSystem macros; diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index f1d579d541b..fae1915c55c 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -84,7 +84,6 @@ default void sendYieldUpdate(YieldUpdate update) { YieldController getYieldController(); - /** Setter dispatches the per-PCH envelope; reads go via getYieldController(). */ - void setYieldBoolPref(FPref pref, boolean value); - void setYieldStringPref(FPref pref, String value); + /** Setter dispatches the per-PCH envelope; reads go via getYieldController(). Pref values are String-typed in {@link forge.localinstance.properties.PreferencesStore}, so callers wrap booleans with {@code String.valueOf}. */ + void setYieldPref(FPref pref, String value); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index c6374a704ae..92b0ff5eec8 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3545,14 +3545,9 @@ public void applyYieldUpdate(final YieldUpdate update) { } @Override - public void setYieldBoolPref(final FPref pref, final boolean value) { - // Dialog already wrote to FModel; APINA toggles may flip mayAutoPass - tryAutoPassNow(); - } - - @Override - public void setYieldStringPref(final FPref pref, final String value) { - // Dialog already wrote to FModel; nothing else to do for the local player + public void setYieldPref(final FPref pref, final String value) { + // Dialog already wrote to FModel; APINA is the only pref whose toggle can flip mayAutoPass for a sitting prompt + if (pref == FPref.YIELD_AUTO_PASS_NO_ACTIONS) tryAutoPassNow(); } /** Re-evaluate mayAutoPass at the current prompt; click OK if it would now fire. From 723a2b4817d1600bfa56eba238d6ebb8f6dbe3a9 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Fri, 8 May 2026 16:54:42 +0200 Subject: [PATCH 19/36] Remove getters --- .../screens/match/VAutoYieldsAndTriggers.java | 4 ++-- .../match/views/VAutoYieldsAndTriggers.java | 4 ++-- .../forge/screens/match/views/VGameMenu.java | 5 ++--- .../screens/match/views/VYieldOptions.java | 15 ++++----------- .../gamemodes/net/client/NetGameController.java | 16 ---------------- .../java/forge/interfaces/IGameController.java | 10 ++++++---- .../forge/player/PlayerControllerHuman.java | 17 ----------------- 7 files changed, 16 insertions(+), 55 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java index 1dab5bcd3bd..973d6163e0d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java @@ -90,11 +90,11 @@ public VAutoYieldsAndTriggers(final CMatchUI matchUI) { listScroller = new FScrollPane(lstEntries, true); chkDisableYields = new FCheckBox(Localizer.getInstance().getMessage("lblDisableAllAutoYields"), - matchUI.getGameController().getDisableAutoYields()); + matchUI.getGameController().getYieldController().getDisableAutoYields()); chkDisableYields.addChangeListener(e -> matchUI.getGameController().setDisableAutoYields(chkDisableYields.isSelected())); chkDisableTriggers = new FCheckBox(Localizer.getInstance().getMessage("lblDisableAllAutoTriggers"), - matchUI.getGameController().getDisableAutoTriggers()); + matchUI.getGameController().getYieldController().getDisableAutoTriggers()); chkDisableTriggers.addChangeListener(e -> matchUI.getGameController().setDisableAutoTriggers(chkDisableTriggers.isSelected())); btnOk = new FButton(Localizer.getInstance().getMessage("lblOK")); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java b/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java index 39e8fb76d12..0e553e95556 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java @@ -65,10 +65,10 @@ protected boolean allowDefaultItemWrap() { } }); chkDisableYields = add(new FCheckBox(Forge.getLocalizer().getMessage("lblDisableAllAutoYields"), - MatchController.instance.getGameController().getDisableAutoYields())); + MatchController.instance.getGameController().getYieldController().getDisableAutoYields())); chkDisableYields.setCommand(e -> MatchController.instance.getGameController().setDisableAutoYields(chkDisableYields.isSelected())); chkDisableTriggers = add(new FCheckBox(Forge.getLocalizer().getMessage("lblDisableAllAutoTriggers"), - MatchController.instance.getGameController().getDisableAutoTriggers())); + MatchController.instance.getGameController().getYieldController().getDisableAutoTriggers())); chkDisableTriggers.setCommand(e -> MatchController.instance.getGameController().setDisableAutoTriggers(chkDisableTriggers.isSelected())); initButton(0, Forge.getLocalizer().getMessage("lblOK"), e -> hide()); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index 683832a0d54..5694b6abea9 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -19,7 +19,6 @@ public VGameMenu() { @Override protected void buildMenu() { - addItem(new FMenuItem(MatchController.instance.getConcedeCaption(), FSkinImage.CONCEDE, e -> ThreadUtil.invokeInGameThread(MatchController.instance::concede) )); @@ -39,13 +38,13 @@ public void handleEvent(FEvent e) { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblAutoYieldsAndTriggers"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, new FEventHandler() { @Override public void handleEvent(FEvent e) { - final boolean autoYieldsDisabled = MatchController.instance.getGameController().getDisableAutoYields(); + final boolean autoYieldsDisabled = MatchController.instance.getGameController().getYieldController().getDisableAutoYields(); final VAutoYieldsAndTriggers dialog = new VAutoYieldsAndTriggers() { @Override public void setVisible(boolean b0) { super.setVisible(b0); if (!b0) { - if (autoYieldsDisabled && !MatchController.instance.getGameController().getDisableAutoYields()) { + if (autoYieldsDisabled && !MatchController.instance.getGameController().getYieldController().getDisableAutoYields()) { //if re-enabling auto-yields, auto-yield to current ability on stack if applicable if (MatchController.instance.getGameView().peekStack() != null) { final String key = MatchController.instance.getGameView().peekStack().getKey(); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java index a05a8e48a39..33706300466 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java @@ -25,7 +25,6 @@ public class VYieldOptions extends FDialog { - private static final FSkinFont DESC_FONT = FSkinFont.get(12); private static final FSkinFont BODY_FONT = FSkinFont.get(11); private static final FSkinColor DESC_COLOR = FSkinColor.get(Colors.CLR_TEXT).alphaColor(0.55f); @@ -264,20 +263,14 @@ private static int scopeIndex(DeclineScope[] options, String prefValue) { return 0; } - private static void persistBool(IGameController ctrl, FPref pref, boolean value) { - FModel.getPreferences().setPref(pref, value); - FModel.getPreferences().save(); - if (ctrl != null) ctrl.setYieldPref(pref, String.valueOf(value)); - } - private static void persistScope(IGameController ctrl, FPref pref, DeclineScope[] options, int index) { if (index < 0 || index >= options.length) return; String value = options[index].name(); - FModel.getPreferences().setPref(pref, value); - FModel.getPreferences().save(); - if (ctrl != null) ctrl.setYieldPref(pref, value); + persistString(ctrl, pref, value); + } + private static void persistBool(IGameController ctrl, FPref pref, boolean value) { + persistString(ctrl, pref, String.valueOf(value)); } - private static void persistString(IGameController ctrl, FPref pref, String value) { FModel.getPreferences().setPref(pref, value); FModel.getPreferences().save(); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 5814e13a86f..7def41f4957 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -137,40 +137,24 @@ public void requestResync() { send(ProtocolMethod.requestResync); } - @Override - public boolean shouldAutoYield(final String key) { - return yieldController.shouldAutoYield(key); - } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { String storageKey = yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.CardAutoYield(storageKey, autoYield, isAbilityScope)); } - @Override - public boolean getDisableAutoYields() { - return yieldController.getDisableAutoYields(); - } @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetDisableYields(disable)); } - @Override - public AutoYieldStore.TriggerDecision getTriggerDecision(final String key) { - return yieldController.getTriggerDecision(key); - } @Override public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDecision decision, final boolean isAbilityScope) { String storageKey = yieldController.setTriggerDecision(key, decision, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(storageKey, decision, isAbilityScope)); } - @Override - public boolean getDisableAutoTriggers() { - return yieldController.getDisableAutoTriggers(); - } @Override public void setDisableAutoTriggers(final boolean disable) { yieldController.setDisableAutoTriggers(disable); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index fae1915c55c..57ceb28ed06 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -55,20 +55,22 @@ public interface IGameController { void passPriority(); // Auto-yield preferences - boolean shouldAutoYield(String key); + default boolean shouldAutoYield(String key) { + return getYieldController().shouldAutoYield(key); + } /** * @param isAbilityScope true if {@code key} is an ability suffix (Per Ability * modes); * false if {@code key} is the full raw key (Per Card mode). Server-side handlers * route storage by this flag instead of consulting the host's own UI_AUTO_DECISION_MODE. */ void setShouldAutoYield(String key, boolean autoYield, boolean isAbilityScope); - boolean getDisableAutoYields(); void setDisableAutoYields(boolean disable); // Trigger accept/decline preferences - TriggerDecision getTriggerDecision(String key); + default TriggerDecision getTriggerDecision(String key) { + return getYieldController().getTriggerDecision(key); + } void setTriggerDecision(String key, TriggerDecision decision, boolean isAbilityScope); - boolean getDisableAutoTriggers(); void setDisableAutoTriggers(boolean disable); /** Apply a unified yield update envelope to this controller's YieldController. */ diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 92b0ff5eec8..ad176e13022 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3482,29 +3482,16 @@ public void autoPassCancel() { getGui().awaitNextInput(); } - @Override - public boolean shouldAutoYield(final String key) { - return yieldController.shouldAutoYield(key); - } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); } - @Override - public boolean getDisableAutoYields() { - return yieldController.getDisableAutoYields(); - } @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); } - @Override - public AutoYieldStore.TriggerDecision getTriggerDecision(final String key) { - return yieldController.getTriggerDecision(key); - } - @Override public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDecision decision, final boolean isAbilityScope) { yieldController.setTriggerDecision(key, decision, isAbilityScope); @@ -3519,10 +3506,6 @@ public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDec } } - @Override - public boolean getDisableAutoTriggers() { - return yieldController.getDisableAutoTriggers(); - } @Override public void setDisableAutoTriggers(final boolean disable) { yieldController.setDisableAutoTriggers(disable); From dab3fe38932954b9883513892763f14555f8b8fd Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Fri, 8 May 2026 21:27:41 +0200 Subject: [PATCH 20/36] Clean up obsolete refactor --- .../src/forge/screens/match/MatchScreen.java | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index d24ec20160a..c10bd314bed 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -376,32 +376,24 @@ protected void drawOverlay(Graphics g) { if (n > 1) { int idxConcede = 0; int idxAutoYields = 1; - int idxSettings = !Forge.isMobileAdventureMode ? n - 2 : -1; // Settings is second-from-last - int idxShowWinLose = !Forge.isMobileAdventureMode ? n - 1 : -1; // Show Win/Lose is last - if (viewWinLose == null) { - gameMenu.getChildAt(idxConcede).setEnabled(!game.isMulligan()); - gameMenu.getChildAt(idxAutoYields).setEnabled(!game.isMulligan()); - if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(idxSettings).setEnabled(!game.isMulligan()); - gameMenu.getChildAt(idxShowWinLose).setEnabled(false); - } - } else { - gameMenu.getChildAt(idxConcede).setEnabled(false); - gameMenu.getChildAt(idxAutoYields).setEnabled(false); - if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(idxSettings).setEnabled(false); - gameMenu.getChildAt(idxShowWinLose).setEnabled(true); - } + int idxSettings = n - 2; // Settings is second-from-last + int idxShowWinLose = n - 1; // Show Win/Lose is last + boolean gameOver = viewWinLose != null; + boolean canSwitch = !gameOver && !game.isMulligan(); + gameMenu.getChildAt(idxConcede).setEnabled(canSwitch); + gameMenu.getChildAt(idxAutoYields).setEnabled(canSwitch); + if (!Forge.isMobileAdventureMode) { + gameMenu.getChildAt(idxSettings).setEnabled(canSwitch); + gameMenu.getChildAt(idxShowWinLose).setEnabled(gameOver); } } } - if (devMenu != null) { - if (devMenu.isVisible()) { - try { - //rollbackphase enable -- todo limit by gametype? - devMenu.getChildAt(2).setEnabled(game.getPlayers().size() == 2 && game.getStack().size() == 0 && !GuiBase.isNetPlay(MatchController.instance) && game.getPhase().isMain() && !game.getPlayerTurn().isAI()); - } catch (Exception e) {/*NPE when the game hasn't started yet and you click dev mode*/} - } + + if (devMenu != null && devMenu.isVisible()) { + try { + //rollbackphase enable -- todo limit by gametype? + devMenu.getChildAt(2).setEnabled(game.getPlayers().size() == 2 && game.getStack().size() == 0 && !GuiBase.isNetPlay(MatchController.instance) && game.getPhase().isMain() && !game.getPlayerTurn().isAI()); + } catch (Exception e) {/*NPE when the game hasn't started yet and you click dev mode*/} } if (activeEffect != null) { From 928f87026b00e8ef2b84fdd22222b26178bfee68 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 9 May 2026 06:50:02 +0930 Subject: [PATCH 21/36] Unify ESC yield-cancel through Input layer V2 introduced two parallel mechanisms for yield-clear that bypassed the Input layer: VPrompt.buttonKeyAdapter ran an unconditional triple sendYieldUpdate ahead of btnCancel.doClick(), and SHORTCUT_YIELD_CANCEL sent the same triple as a global keyboard action. The KeyAdapter also regressed the MVP precedence rule -- cancel-click takes priority over yield-clear so ESC during a confirm/mana-pay prompt doesn't silently drop an armed yield. - VPrompt: revert KeyAdapter to master form. Consume the event after cancel-click so the global shortcut doesn't double-fire on the same ESC. - KeyboardShortcuts: actCancelYield now calls ctrl.selectButtonCancel() instead of sending envelopes directly. Single yield-cancel path through the Input layer; InputLockUI.selectButtonCancel triggers clearActiveYieldAndDispatch as a side effect. Tradeoff: the shortcut no longer clears yield while the host is in a non-LockUI Input (e.g. mid-mana-pay). User dismisses the active prompt first, then ESC clears yield -- consistent with normal layered-ESC UI conventions. Addresses review on PR #10606. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/control/KeyboardShortcuts.java | 10 +++------ .../forge/screens/match/views/VPrompt.java | 22 +++---------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 0f5433926a6..fe08914cfb9 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -18,9 +18,7 @@ import org.apache.commons.lang3.StringUtils; import forge.Singletons; -import forge.game.player.PlayerView; import forge.game.spellability.StackItemView; -import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.EDocID; import forge.interfaces.IGameController; import forge.gui.framework.SDisplayUtil; @@ -289,12 +287,10 @@ public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } IGameController ctrl = matchUI.getGameController(); - PlayerView local = matchUI.getCurrentPlayer(); - if (ctrl == null || local == null || ctrl.getYieldController() == null) { return; } + if (ctrl == null || ctrl.getYieldController() == null) { return; } if (!ctrl.getYieldController().isYieldActive()) { return; } - ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false, false)); - ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); + // InputLockUI.selectButtonCancel calls clearActiveYieldAndDispatch as a side effect. + ctrl.selectButtonCancel(); } }; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 9483e34067d..fce937000d3 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -30,13 +30,9 @@ import javax.swing.SwingConstants; import forge.game.card.CardView; -import forge.game.player.PlayerView; -import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; -import forge.interfaces.IGameController; -import forge.screens.match.CMatchUI; import forge.gui.framework.IVDoc; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; @@ -78,24 +74,12 @@ public void setCardView(final CardView card) { private KeyAdapter buttonKeyAdapter = new KeyAdapter() { @Override public void keyPressed(final KeyEvent e) { - if (e.getKeyCode() != KeyEvent.VK_ESCAPE) return; - - // sendYieldUpdate (not applyYieldUpdate) so the clear reaches the host's PCH proxy - CMatchUI matchUI = controller.getMatchUI(); - IGameController ctrl = matchUI == null ? null : matchUI.getGameController(); - PlayerView local = matchUI == null ? null : matchUI.getCurrentPlayer(); - if (ctrl != null && local != null && ctrl.getYieldController() != null - && ctrl.getYieldController().isYieldActive()) { - ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false, false)); - ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); - return; - } - - if (btnCancel.isEnabled() + if (e.getKeyCode() == KeyEvent.VK_ESCAPE + && btnCancel.isEnabled() && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { btnCancel.doClick(); + e.consume(); // prevent SHORTCUT_YIELD_CANCEL from also firing on this ESC } } }; From 0c23beef29af70278acca9c199baff034d2e376f Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 9 May 2026 12:15:24 +0930 Subject: [PATCH 22/36] Split yield shortcuts: F2 stop-all, F3 APINA toggle Repartitions the yield keyboard surface so ESC carries no yield-specific role and a single unconditional key clears all yield state regardless of what prompt is focused. - F2 (was APINA toggle) becomes "stop all yields": calls YieldController.stopAllYields, which clears transient yield state via clearActiveYieldAndDispatch and turns APINA off if it's on. Lives in the global WHEN_IN_FOCUSED_WINDOW InputMap and isn't intercepted by VPrompt.buttonKeyAdapter (which only consumes ESC), so it fires through mana-pay or confirm prompts without dismissing them. - F3 (was unbound) becomes the pure APINA toggle. - SHORTCUT_YIELD_CANCEL deleted -- ESC already reaches btnCancel via VPrompt's KeyAdapter, so the dedicated binding was a no-op duplicate. Each key now has one unconditional behavior: ESC clicks cancel, F2 stops everything yield-related, F3 toggles APINA. No mode-dependent layering on any single key. User-Guide and advanced-yield-options updated to match. Addresses review on PR #10606. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/User-Guide.md | 4 ++-- docs/advanced-yield-options.md | 2 +- .../java/forge/control/KeyboardShortcuts.java | 17 ++++++----------- .../java/forge/screens/match/views/VPrompt.java | 2 +- forge-gui/res/languages/en-US.properties | 4 ++-- .../forge/gamemodes/match/YieldController.java | 10 ++++++++++ .../properties/ForgePreferences.java | 4 ++-- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/User-Guide.md b/docs/User-Guide.md index a2af1533e61..ea798662775 100644 --- a/docs/User-Guide.md +++ b/docs/User-Guide.md @@ -198,12 +198,12 @@ When paying mana costs, you can press Enter/Spacebar or click the Auto button in Forge offers several yield options depending on how long you want to skip prompts: -- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or F2 hoktey, or on **Mobile** from the in-match Game menu. +- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or F3 hotkey, or on **Mobile** from the in-match Game menu. - **End Turn** — auto-pass through the rest of the current turn, bypassing any phase stops. Triggered by the End Turn dock button. - **Yield markers** — auto-pass until a specific phase is reached. Right-click (or long-press) a phase indicator to set one; a fast-forward symbol marks the active cell. Each (player, phase) cell is independent, so in multiplayer you can yield to a specific opponent's end step. - **Yield to stack / Resolve entire stack** — auto-pass while the stack resolves. Right-click a stack item to choose: **Yield to stack** auto-passes until the stack empties or an interrupt fires (for example, an opponent casts another spell); **Resolve entire stack** keeps auto-passing until the whole stack is empty even if opponents cast more spells. -By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. +By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. Press **F2** to clear every active yield in one shot — including the Auto-Pass toggle — useful when a prompt is up and you want to abandon all yielding without dismissing the prompt. ## Auto-Yield and Trigger Decisions - When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept it (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index 7a4e65528a3..1c49cb40d7d 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -28,7 +28,7 @@ These features are highly configurable through the **Yield Settings** dialog, an ## Auto-Pass -**Auto-Pass** is a persistent toggle (F2 on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. +**Auto-Pass** is a persistent toggle (F3 on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. **How it works:** - When enabled, Forge scans your hand, battlefield, and external zones (graveyard, exile, command) for castable spells, playable lands, and activatable abilities. diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index fe08914cfb9..a85c0a25170 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -20,7 +20,6 @@ import forge.Singletons; import forge.game.spellability.StackItemView; import forge.gui.framework.EDocID; -import forge.interfaces.IGameController; import forge.gui.framework.SDisplayUtil; import forge.localinstance.properties.ForgePreferences; import forge.gamemodes.match.YieldController; @@ -272,25 +271,21 @@ public void actionPerformed(final ActionEvent e) { } }; - final Action actAutoPassNoActions = new AbstractAction() { + final Action actStopAllYields = new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } - YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + YieldController.stopAllYields(matchUI.getGameController()); } }; - final Action actCancelYield = new AbstractAction() { + final Action actToggleApina = new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } - IGameController ctrl = matchUI.getGameController(); - if (ctrl == null || ctrl.getYieldController() == null) { return; } - if (!ctrl.getYieldController().isYieldActive()) { return; } - // InputLockUI.selectButtonCancel calls clearActiveYieldAndDispatch as a side effect. - ctrl.selectButtonCancel(); + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); } }; @@ -308,8 +303,8 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_YES, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_YES"), actAutoYieldAndYes, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_NO, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_NO"), actAutoYieldAndNo, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_OPTIONS, localizer.getMessage("lblSHORTCUT_YIELD_OPTIONS"), actYieldOptions, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_AUTO_PASS, localizer.getMessage("lblSHORTCUT_YIELD_AUTO_PASS"), actAutoPassNoActions, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_CANCEL, localizer.getMessage("lblSHORTCUT_YIELD_CANCEL"), actCancelYield, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_STOP_ALL, localizer.getMessage("lblSHORTCUT_YIELD_STOP_ALL"), actStopAllYields, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_TOGGLE_APINA, localizer.getMessage("lblSHORTCUT_YIELD_TOGGLE_APINA"), actToggleApina, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_RECORD, localizer.getMessage("lblSHORTCUT_MACRO_RECORD"), actMacroRecord, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_NEXT_ACTION, localizer.getMessage("lblSHORTCUT_MACRO_NEXT_ACTION"), actMacroNextAction, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CARD_ZOOM, localizer.getMessage("lblSHORTCUT_CARD_ZOOM"), actZoomCard, am, im)); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index fce937000d3..d43abed3bb3 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -79,7 +79,7 @@ public void keyPressed(final KeyEvent e) { && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { btnCancel.doClick(); - e.consume(); // prevent SHORTCUT_YIELD_CANCEL from also firing on this ESC + e.consume(); } } }; diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 9cd9aa58770..168081ddef8 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1664,8 +1664,8 @@ lblSpeedSettings=Speed Settings lblSkipPhaseDelay=Skip delay between phases lblSkipResolveDelay=Skip delay when stack resolves lblSHORTCUT_YIELD_OPTIONS=Yield: Toggle Yield Options -lblSHORTCUT_YIELD_AUTO_PASS=Yield: Toggle Auto-Pass -lblSHORTCUT_YIELD_CANCEL=Yield: Cancel Active Yield +lblSHORTCUT_YIELD_STOP_ALL=Yield: Stop All Yields +lblSHORTCUT_YIELD_TOGGLE_APINA=Yield: Toggle Auto-Pass lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index d62550f23dd..76e2afef728 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -463,6 +463,16 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { return newVal; } + /** Clear all transient yield state and turn APINA off if it's on — single unified stop action. */ + public static void stopAllYields(IGameController ctrl) { + if (ctrl == null) return; + YieldController yc = ctrl.getYieldController(); + if (yc != null) yc.clearActiveYieldAndDispatch(); + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + toggleAutoPassNoActions(ctrl); + } + } + /** Activate auto-pass-until-end-of-turn for {@code local} via the unified envelope path. */ public static void endTurn(IGameController ctrl, PlayerView local) { if (ctrl == null || local == null) return; diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 40942a576ba..9953532f91d 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -322,8 +322,8 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_AUTOYIELD_ALWAYS_YES ("89"), SHORTCUT_AUTOYIELD_ALWAYS_NO ("78"), SHORTCUT_YIELD_OPTIONS ("17 89"), - SHORTCUT_YIELD_AUTO_PASS ("113"), - SHORTCUT_YIELD_CANCEL ("27"), + SHORTCUT_YIELD_STOP_ALL ("113"), + SHORTCUT_YIELD_TOGGLE_APINA ("114"), SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), From 5f34180923b7d863d0668c283ecb16a72617776b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 9 May 2026 17:22:18 +0930 Subject: [PATCH 23/36] Unify yield shortcut: P toggles Auto-Pass or cancels active yields Replace the F2/F3 split with a single P key. If any yield is currently active (transient state or APINA), the key cancels everything; otherwise it turns Auto-Pass on. Y would have been the obvious mnemonic for "Yield" but it's already the default for auto-yes on stack items (SHORTCUT_AUTOYIELD_ALWAYS_YES); P for "Pass" is the next-best fit and keeps shortcuts off F-keys. Also refresh the dock auto-pass icon highlight after the keyboard path runs -- previously only the dock-button click refreshed it, so pressing the key left the icon stale until the next CDock.update. Drive-by: reword the End Turn shortcut's description to "pass priority until end of turn or interrupt", matching the "interrupt" wording introduced by the renamed yield-to-stack option. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/User-Guide.md | 4 ++-- docs/advanced-yield-options.md | 2 +- .../java/forge/control/KeyboardShortcuts.java | 17 ++++------------- .../forge/screens/match/controllers/CDock.java | 2 +- forge-gui/res/languages/en-US.properties | 7 +++---- .../forge/gamemodes/match/YieldController.java | 16 ++++++++++++---- .../properties/ForgePreferences.java | 3 +-- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/docs/User-Guide.md b/docs/User-Guide.md index ea798662775..a268341e2a5 100644 --- a/docs/User-Guide.md +++ b/docs/User-Guide.md @@ -198,12 +198,12 @@ When paying mana costs, you can press Enter/Spacebar or click the Auto button in Forge offers several yield options depending on how long you want to skip prompts: -- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or F3 hotkey, or on **Mobile** from the in-match Game menu. +- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or the **P** hotkey, or on **Mobile** from the in-match Game menu. - **End Turn** — auto-pass through the rest of the current turn, bypassing any phase stops. Triggered by the End Turn dock button. - **Yield markers** — auto-pass until a specific phase is reached. Right-click (or long-press) a phase indicator to set one; a fast-forward symbol marks the active cell. Each (player, phase) cell is independent, so in multiplayer you can yield to a specific opponent's end step. - **Yield to stack / Resolve entire stack** — auto-pass while the stack resolves. Right-click a stack item to choose: **Yield to stack** auto-passes until the stack empties or an interrupt fires (for example, an opponent casts another spell); **Resolve entire stack** keeps auto-passing until the whole stack is empty even if opponents cast more spells. -By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. Press **F2** to clear every active yield in one shot — including the Auto-Pass toggle — useful when a prompt is up and you want to abandon all yielding without dismissing the prompt. +By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. The **P** hotkey acts as a single yield switch: if any yield is currently active (including Auto-Pass), it clears them all without dismissing any open prompt; otherwise it turns Auto-Pass on. ## Auto-Yield and Trigger Decisions - When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept it (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index 1c49cb40d7d..deddef2637a 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -28,7 +28,7 @@ These features are highly configurable through the **Yield Settings** dialog, an ## Auto-Pass -**Auto-Pass** is a persistent toggle (F3 on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. +**Auto-Pass** is a persistent toggle (**P** on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. The same **P** key also doubles as a "stop everything" shortcut: if any yield is currently active (transient yield or Auto-Pass itself), pressing P clears them all in one shot without dismissing any prompt that's up. **How it works:** - When enabled, Forge scans your hand, battlefield, and external zones (graveyard, exile, command) for castable spells, playable lands, and activatable abilities. diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index a85c0a25170..eb3d7facf04 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -271,21 +271,13 @@ public void actionPerformed(final ActionEvent e) { } }; - final Action actStopAllYields = new AbstractAction() { + final Action actYieldAutoPass = new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } - YieldController.stopAllYields(matchUI.getGameController()); - } - }; - - final Action actToggleApina = new AbstractAction() { - @Override - public void actionPerformed(final ActionEvent e) { - if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } - YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + YieldController.toggleAutoPassOrStopAll(matchUI.getGameController()); + matchUI.getCDock().refreshAutoPassToggled(); } }; @@ -303,8 +295,7 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_YES, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_YES"), actAutoYieldAndYes, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_NO, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_NO"), actAutoYieldAndNo, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_OPTIONS, localizer.getMessage("lblSHORTCUT_YIELD_OPTIONS"), actYieldOptions, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_STOP_ALL, localizer.getMessage("lblSHORTCUT_YIELD_STOP_ALL"), actStopAllYields, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_TOGGLE_APINA, localizer.getMessage("lblSHORTCUT_YIELD_TOGGLE_APINA"), actToggleApina, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_AUTO_PASS, localizer.getMessage("lblSHORTCUT_YIELD_AUTO_PASS"), actYieldAutoPass, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_RECORD, localizer.getMessage("lblSHORTCUT_MACRO_RECORD"), actMacroRecord, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_NEXT_ACTION, localizer.getMessage("lblSHORTCUT_MACRO_NEXT_ACTION"), actMacroNextAction, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CARD_ZOOM, localizer.getMessage("lblSHORTCUT_CARD_ZOOM"), actZoomCard, am, im)); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java index 6287745ed13..ea77395e351 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java @@ -144,7 +144,7 @@ private void toggleAutoPass() { refreshAutoPassToggled(); } - private void refreshAutoPassToggled() { + public void refreshAutoPassToggled() { view.getBtnAutoPass().setToggled(FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 168081ddef8..6ac25b51189 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -517,7 +517,7 @@ lblSHORTCUT_SHOWCONSOLE=Match: show console panel lblSHORTCUT_SHOWDEV=Match: show dev panel lblSHORTCUT_UNDO=Match: undo last action lblSHORTCUT_CONCEDE=Match: concede game -lblSHORTCUT_ENDTURN=Match: pass priority until EOT or next stack event +lblSHORTCUT_ENDTURN=Match: pass priority until end of turn or interrupt lblSHORTCUT_ALPHASTRIKE=Match: Alpha Strike (attack with all available) lblSHORTCUT_SHOWTARGETING=Match: toggle targeting visual overlay lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Match: auto-yield ability on stack (Always Yes) @@ -1663,9 +1663,8 @@ lblYieldSuggestionDeclineHintStack=(Declining disables this prompt until the sta lblSpeedSettings=Speed Settings lblSkipPhaseDelay=Skip delay between phases lblSkipResolveDelay=Skip delay when stack resolves -lblSHORTCUT_YIELD_OPTIONS=Yield: Toggle Yield Options -lblSHORTCUT_YIELD_STOP_ALL=Yield: Stop All Yields -lblSHORTCUT_YIELD_TOGGLE_APINA=Yield: Toggle Auto-Pass +lblSHORTCUT_YIELD_OPTIONS=Yield: open Yield Options menu +lblSHORTCUT_YIELD_AUTO_PASS=Yield: toggle Auto-Pass / cancel active yields lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 76e2afef728..5c144c0e1d0 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -463,12 +463,20 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { return newVal; } - /** Clear all transient yield state and turn APINA off if it's on — single unified stop action. */ - public static void stopAllYields(IGameController ctrl) { + /** + * Single unified yield-key action. If any yield is currently active (transient + * yield state or APINA on), clears everything. Otherwise turns APINA on. So one + * key acts as both "stop any yielding" and "start auto-passing" depending on state. + */ + public static void toggleAutoPassOrStopAll(IGameController ctrl) { if (ctrl == null) return; YieldController yc = ctrl.getYieldController(); - if (yc != null) yc.clearActiveYieldAndDispatch(); - if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + boolean transientActive = yc != null && yc.isYieldActive(); + boolean apinaOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + if (transientActive || apinaOn) { + if (yc != null) yc.clearActiveYieldAndDispatch(); + if (apinaOn) toggleAutoPassNoActions(ctrl); + } else { toggleAutoPassNoActions(ctrl); } } diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 9953532f91d..0e463de5de9 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -322,8 +322,7 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_AUTOYIELD_ALWAYS_YES ("89"), SHORTCUT_AUTOYIELD_ALWAYS_NO ("78"), SHORTCUT_YIELD_OPTIONS ("17 89"), - SHORTCUT_YIELD_STOP_ALL ("113"), - SHORTCUT_YIELD_TOGGLE_APINA ("114"), + SHORTCUT_YIELD_AUTO_PASS ("80"), SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), From b6f104ab36ac7676e86902047416a17a765939ab Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 9 May 2026 18:15:20 +0930 Subject: [PATCH 24/36] Translate updated lblSHORTCUT_ENDTURN into other locales Co-Authored-By: Claude Opus 4.7 (1M context) --- forge-gui/res/languages/de-DE.properties | 2 +- forge-gui/res/languages/es-ES.properties | 2 +- forge-gui/res/languages/fr-FR.properties | 2 +- forge-gui/res/languages/it-IT.properties | 2 +- forge-gui/res/languages/ja-JP.properties | 2 +- forge-gui/res/languages/ko-KR.properties | 2 +- forge-gui/res/languages/pt-BR.properties | 2 +- forge-gui/res/languages/zh-CN.properties | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index 2c00dd76f8c..f8bddf8022b 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -451,7 +451,7 @@ lblSHORTCUT_SHOWCOMBAT=Duell: Zeige Kampffenster lblSHORTCUT_SHOWCONSOLE=Duell: Zeige Konsolenfenster lblSHORTCUT_SHOWDEV=Duell: Zeige Entwicklerfenster lblSHORTCUT_CONCEDE=Duell: Spiel aufgeben -lblSHORTCUT_ENDTURN=Duell: Priorität bis Zugende oder nächstem Ereignis abgeben +lblSHORTCUT_ENDTURN=Duell: Priorität bis Zugende oder Unterbrechung abgeben lblSHORTCUT_ALPHASTRIKE=Duell: Alpha Strike (Mit allem angreifen) lblSHORTCUT_SHOWTARGETING=Duell: Zielpfeile umschalten lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Duel: Auto-Bestätigen von Fähigkeiten auf dem Stapel (immer Ja) diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 95038ff3577..ad15d021838 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -428,7 +428,7 @@ lblSHORTCUT_SHOWCOMBAT=Partida: mostrar panel de combate lblSHORTCUT_SHOWCONSOLE=Partida: mostrar el panel de la consola lblSHORTCUT_SHOWDEV=Partida: mostrar panel de desarrollo lblSHORTCUT_CONCEDE=Partida: conceder juego -lblSHORTCUT_ENDTURN=Partida: pasa la prioridad hasta fin del turno o siguiente evento de pila +lblSHORTCUT_ENDTURN=Partida: pasa la prioridad hasta fin del turno o interrupción lblSHORTCUT_ALPHASTRIKE=Partida: Alpha Strike (ataque con todos los disponibles) lblSHORTCUT_SHOWTARGETING=Partida: alternar la orientación visual de superposición lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Partida: ceder automáticamente en cada habilidad de la pila (Siempre Sí) diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index 608628d2410..e2166df89da 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -426,7 +426,7 @@ lblSHORTCUT_SHOWCOMBAT=Match : afficher le panneau de combat lblSHORTCUT_SHOWCONSOLE=Correspondance : afficher le panneau de la console lblSHORTCUT_SHOWDEV=Correspondance : afficher le panneau de développement lblSHORTCUT_CONCEDE=Match : concéder le jeu -lblSHORTCUT_ENDTURN=Match : passer la priorité jusqu'à l'EOT ou le prochain événement de pile +lblSHORTCUT_ENDTURN=Match : passer la priorité jusqu'à la fin du tour ou interruption lblSHORTCUT_ALPHASTRIKE=Match : Frappe alpha (attaque avec tous disponibles) lblSHORTCUT_SHOWTARGETING=Correspondance : basculer la superposition visuelle du ciblage lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Match : capacité de rendement automatique sur la pile (toujours oui) diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index 59b8319b121..d3b6002016d 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -425,7 +425,7 @@ lblSHORTCUT_SHOWCOMBAT=Incontro: mostra il pannello di combattimento lblSHORTCUT_SHOWCONSOLE=Incontro: mostra il pannello della console lblSHORTCUT_SHOWDEV=Incontro: mostra il pannello per gli sviluppatori lblSHORTCUT_CONCEDE=Incontro: concedi la partita -lblSHORTCUT_ENDTURN=Incontro: passa la priorità fino alla fine del turno o al prossimo evento della pila +lblSHORTCUT_ENDTURN=Incontro: passa la priorità fino alla fine del turno o interruzione lblSHORTCUT_ALPHASTRIKE=Incontro: Alpha Strike lblSHORTCUT_SHOWTARGETING=Incontro: attiva / disattiva gli indicatori dei bersagli lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Incontro: consenso automatico in pila: sempre Sì diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index e495c3e99f6..d0a2ccf7f7a 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -426,7 +426,7 @@ lblSHORTCUT_SHOWCOMBAT=戦闘パネルを表示 lblSHORTCUT_SHOWCONSOLE=コンソールパネルを表示 lblSHORTCUT_SHOWDEV=開発者パネルを表示 lblSHORTCUT_CONCEDE=ゲームを投了する -lblSHORTCUT_ENDTURN=ターンエンドまたは次のスタックイベントまで優先権を渡す +lblSHORTCUT_ENDTURN=ターンエンドまたは割り込みまで優先権を渡す lblSHORTCUT_ALPHASTRIKE=全員突撃(すべての攻撃可能なクリーチャーを指定) lblSHORTCUT_SHOWTARGETING=ビジュアルオーバーレイのターゲットを切り替える lblSHORTCUT_AUTOYIELD_ALWAYS_YES=スタック解決時、優先権の自動放棄(常にはい) diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 1e3e7b83347..bcacae6a4dd 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -460,7 +460,7 @@ lblSHORTCUT_SHOWCOMBAT=전투 패널 표시 lblSHORTCUT_SHOWCONSOLE=콘솔 패널 표시 lblSHORTCUT_SHOWDEV=개발자 패널 표시 lblSHORTCUT_CONCEDE=게임을 포기합니다 -lblSHORTCUT_ENDTURN=턴 종료 또는 다음 스택 이벤트까지 우선권 전달 +lblSHORTCUT_ENDTURN=턴 종료 또는 인터럽트까지 우선권 전달 lblSHORTCUT_ALPHASTRIKE=전원 돌격 (모든 공격 가능한 크리처 지정) lblSHORTCUT_SHOWTARGETING=시각적 오버레이 타깃 전환 lblSHORTCUT_AUTOYIELD_ALWAYS_YES=스택 해결 시, 우선권 자동 포기 (항상 예) diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index b3acf235a82..ac7b1174476 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -438,7 +438,7 @@ lblSHORTCUT_SHOWCOMBAT=Partida\: mostrar painel de combate lblSHORTCUT_SHOWCONSOLE=Partida\: mostrar painel de console lblSHORTCUT_SHOWDEV=Partida\: mostrar painel dev lblSHORTCUT_CONCEDE=Partida\: conceda o jogo -lblSHORTCUT_ENDTURN=Partida\: passar prioridade até Final Turno ou próximo evento da pilha +lblSHORTCUT_ENDTURN=Partida\: passar prioridade até Final Turno ou interrupção lblSHORTCUT_ALPHASTRIKE=Partida\: Ataque Alfa (ataque com todos disponíveis) lblSHORTCUT_SHOWTARGETING=Partida\: mudar a indicação visual do alvo lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Partida\: resolver automático a pilha (Sempre Sim) diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index c5c63cd75e5..9e46aeae650 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -428,7 +428,7 @@ lblSHORTCUT_SHOWCOMBAT=匹配:显示战斗面板 lblSHORTCUT_SHOWCONSOLE=匹配:显示控制台面板 lblSHORTCUT_SHOWDEV=匹配:显示开发人员模式面板 lblSHORTCUT_CONCEDE=匹配:认输 -lblSHORTCUT_ENDTURN=匹配:让过优先权直到回合结束或者下一个堆叠事件 +lblSHORTCUT_ENDTURN=匹配:让过优先权直到回合结束或打断 lblSHORTCUT_ALPHASTRIKE=匹配:先攻 lblSHORTCUT_SHOWTARGETING=匹配:切换视觉叠加层 lblSHORTCUT_AUTOYIELD_ALWAYS_YES=匹配:自动让过堆叠中的异能(所有都选是) From 64b26fc53e257f2484bc8fcf2960216317beeb85 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 9 May 2026 12:39:45 +0200 Subject: [PATCH 25/36] Refinements --- docs/User-Guide.md | 26 +++++++++---------- .../gamemodes/net/TrackableSerializer.java | 11 +++----- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/docs/User-Guide.md b/docs/User-Guide.md index a268341e2a5..435e1a1c1f9 100644 --- a/docs/User-Guide.md +++ b/docs/User-Guide.md @@ -191,33 +191,31 @@ When paying mana costs, you can press Enter/Spacebar or click the Auto button in - you'll still be prompted when paying Sunburst or cards that care what colors are spent to cast it (ex. Firespout). ## Auto-Pass and Yield Options -> [!NOTE] -> For more information and configuration options — including interrupt conditions, automatic yield suggestions, and speed settings — see [Advanced Yield Options](advanced-yield-options.md). - **Yielding** lets you hand priority to Forge so it passes on your behalf instead of you needing to click through every priority pass. This helps you get to your desired phase of action quickly. Forge offers several yield options depending on how long you want to skip prompts: -- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon or the **P** hotkey, or on **Mobile** from the in-match Game menu. +- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon and the **P** hotkey, or on **Mobile** from the in-match Game menu. - **End Turn** — auto-pass through the rest of the current turn, bypassing any phase stops. Triggered by the End Turn dock button. - **Yield markers** — auto-pass until a specific phase is reached. Right-click (or long-press) a phase indicator to set one; a fast-forward symbol marks the active cell. Each (player, phase) cell is independent, so in multiplayer you can yield to a specific opponent's end step. - **Yield to stack / Resolve entire stack** — auto-pass while the stack resolves. Right-click a stack item to choose: **Yield to stack** auto-passes until the stack empties or an interrupt fires (for example, an opponent casts another spell); **Resolve entire stack** keeps auto-passing until the whole stack is empty even if opponents cast more spells. -By default, every yield except **Resolve entire stack** cancels automatically when an opponent casts a spell or declares attackers against you, so you can respond or block. You can also press Escape or click Cancel at any time to break out of a yield. The **P** hotkey acts as a single yield switch: if any yield is currently active (including Auto-Pass), it clears them all without dismissing any open prompt; otherwise it turns Auto-Pass on. +> [!NOTE] +> For more information and configuration options — including interrupt conditions, automatic yield suggestions, and speed settings — see [Advanced Yield Options](advanced-yield-options.md). -## Auto-Yield and Trigger Decisions -- When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept it (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. +## Individual Yields and Trigger Decisions +When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. -- The granularity and lifetime of these decisions are controlled by the **Auto Yield/Trigger Mode** setting under Settings → Preferences: +The granularity and lifetime of these decisions are controlled by the **Auto Yield/Trigger Mode** setting under Settings → Preferences: - - **Per Card (Each Game)** — decisions are tied to a specific card instance and cleared at the end of each game. You'll need to set them again in the next game of the match. (For example, auto-yielding one Hellrider does not affect another copy of Hellrider.) - - **Per Ability (Each Match)** — decisions apply to every copy of the ability and persist across games within the current match, then clear when you start a new match. - - **Per Ability (Each Session)** — decisions persist across matches until you close Forge. - - **Per Ability (Each Install)** — decisions are saved to disk and persist across Forge restarts. +- **Per Card (Each Game)** — decisions are tied to a specific card instance and cleared at the end of each game. You'll need to set them again in the next game of the match. (For example, auto-yielding one Hellrider does not affect another copy of Hellrider.) +- **Per Ability (Each Match)** — decisions apply to every copy of the ability and persist across games within the current match, then clear when you start a new match. +- **Per Ability (Each Session)** — decisions persist across matches until you close Forge. +- **Per Ability (Each Install)** — decisions are saved to disk and persist across Forge restarts. -- Pick a longer-lived scope when you want recurring triggers (e.g. routine ETBs, upkeep optional triggers) to stay yielded across many games; pick a shorter scope when you want a clean slate each game. +Pick a longer-lived scope when you want recurring triggers (e.g. routine ETBs, upkeep optional triggers) to stay yielded across many games; pick a shorter scope when you want a clean slate each game. -- The current list of active auto-yields and Always Yes / Always No trigger decisions is visible from Game → Auto-Yields and Triggers, where individual entries can be cleared. +The current list of active auto-yields and Always Yes / Always No trigger decisions is visible from Game → Auto-Yields and Triggers, where individual entries can be cleared. ## Shift Key helper * When you mouse over a flip, transform or Morph (controlled by you) card in battlefield, hold SHIFT to see other state of that card at the side panel that displays card picture and details. diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 4219c6339a8..199015ab7c4 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -8,9 +8,7 @@ import forge.trackable.TrackableTypes; import forge.trackable.TrackableTypes.TrackableType; import forge.trackable.Tracker; - -import org.tinylog.Logger; -import org.tinylog.TaggedLogger; +import forge.util.IHasForgeLog; import net.jpountz.lz4.LZ4BlockOutputStream; @@ -27,8 +25,7 @@ * {@link CompatibleObjectDecoder}) and the mobile codec path * ({@link CObjectOutputStream}, {@link CObjectInputStream}). */ -public final class TrackableSerializer { - private static final TaggedLogger netLog = Logger.tag("NETWORK"); +public final class TrackableSerializer implements IHasForgeLog { static final byte TYPE_CARD_VIEW = 0; static final byte TYPE_PLAYER_VIEW = 1; @@ -151,10 +148,8 @@ public static int measureSize(Object obj, Tracker tracker) { * full object graphs. Unwrapped after delta state is applied, when the * client tracker is populated. */ - static final class WrappedEvent implements Serializable { + record WrappedEvent(byte[] data) implements Serializable { private static final long serialVersionUID = 1L; - final byte[] data; - WrappedEvent(byte[] data) { this.data = data; } } /** From 8acf9a7918b3d14f9fc02e63da4640cea35bff4d Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 9 May 2026 13:50:17 +0200 Subject: [PATCH 26/36] Remove redundancy --- docs/advanced-yield-options.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md index deddef2637a..ef93e9eaa89 100644 --- a/docs/advanced-yield-options.md +++ b/docs/advanced-yield-options.md @@ -21,11 +21,6 @@ Forge offers a range of **Advanced Yield Options** to: These features are highly configurable through the **Yield Settings** dialog, and can be set up to suit your own gameplay preferences. -## How to Access - -- **Desktop:** The Auto-Pass icon and a yield-settings cog appear on the dock panel above the prompt area. Click the cog, open the Game menu > **Yield Settings**, or press Ctrl+Y to bring up the full settings dialog. -- **Mobile:** open the in-match Game menu > **Yield Options**. - ## Auto-Pass **Auto-Pass** is a persistent toggle (**P** on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. The same **P** key also doubles as a "stop everything" shortcut: if any yield is currently active (transient yield or Auto-Pass itself), pressing P clears them all in one shot without dismissing any prompt that's up. @@ -87,12 +82,6 @@ You can decide which game events interrupt a yield: | **Triggered abilities on stack** | OFF | Triggers when triggered abilities are on the stack. | | **Cards revealed or choices made** | OFF | Triggers when reveal dialogs / non-trivial value notifications fire. | -In addition to the six interrupt toggles, this section contains: - -- **Auto-pass respects interrupts** (default OFF). When OFF, Auto-Pass keeps running through every interrupt condition — useful since the whole point of Auto-Pass is to skip prompts when you have no actions. When ON, the same interrupts that cancel yield markers will also cancel Auto-Pass and hand priority back to you. - -**Multiplayer note:** The attackers interrupt is scoped to you specifically. If Player A attacks Player B, your yield will not be interrupted. - ### Automatic Yield Suggestions When the system detects situations where you likely cannot take action, it can prompt you with a yield suggestion. Each suggestion type has a dropdown controlling its decline behavior: @@ -115,7 +104,6 @@ In addition to the per-suggestion dropdowns, this section contains two global su ### Speed Options -- **Auto-pass calculation timeout:** The amount of time in milliseconds the AI has to calculate whether you have any available actions and whether you should auto-pass. If the timeout is reached auto-pass will return false and hand you priority as a safeguard. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). - **Skip delay between phases:** skip Forge's default 200ms delay between each phase resolving. - **Skip delay when stack resolves:** skip Forge's default 400ms delay between items on the stack resolving. From 415c34c83e27c58682fe907e77679a3c3aa90526 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 9 May 2026 14:00:42 +0200 Subject: [PATCH 27/36] Fix redundant label --- forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java | 2 +- forge-gui/res/languages/en-US.properties | 3 --- .../src/main/java/forge/gamemodes/match/YieldController.java | 3 ++- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index 5694b6abea9..ebf4cffe45d 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -63,7 +63,7 @@ public void setVisible(boolean b0) { dialog.show(); } })); - addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldOptions"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldSettings"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { new VYieldOptions().show(); })); boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 6ac25b51189..096707b7ea5 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1628,7 +1628,6 @@ lblYieldingUntilPhaseFmt=Yielding until {0}''s {1}.\nYou may cancel this yield t lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. lblYieldToStack=Yield to stack lblYieldToEntireStack=Resolve entire stack -lblYieldOptions=Yield Options lblYieldSettings=Yield Settings lblYieldBtnAutoPass=Auto-Pass: OFF lblYieldBtnAutoPassOn=Auto-Pass: ON @@ -1652,8 +1651,6 @@ lblSuggestStackYield=When can''t respond to stack lblSuggestNoActions=When no actions available lblSuppressAfterYield=Suppress immediately after yield ends lblSuppressOnOwnTurn=Suppress on own turn -lblDeclScopeNever=Never -lblDeclScopeAlways=Always lblDeclScopeStack=Once per stack lblDeclScopeTurn=Once per turn lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 5c144c0e1d0..b9ca9b7b52c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -41,7 +41,8 @@ */ public class YieldController { - /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. Stored String-typed (see {@link forge.localinstance.properties.PreferencesStore}); consumers parse via {@link #getBoolPref}/{@link #getStringPref} according to the pref's expected type. */ + /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. + * Stored String-typed (see {@link forge.localinstance.properties.PreferencesStore}); consumers parse via {@link #getBoolPref}/{@link #getStringPref} according to the pref's expected type. */ private static final EnumSet SYNCED_PREFS = EnumSet.of( FPref.YIELD_INTERRUPT_ON_ATTACKERS, FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, From f34b8ab654395a52bf8ea5efe959d03c385027ec Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 9 May 2026 14:00:49 +0200 Subject: [PATCH 28/36] Fix redundant label --- .../src/main/java/forge/gamemodes/match/DeclineScope.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java index 1f030295efa..3f60fab1857 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java @@ -2,8 +2,8 @@ /** How long a smart-suggestion decline suppresses the suggestion. Persisted via {@link Enum#name()}. */ public enum DeclineScope { - NEVER("lblDeclScopeNever"), - ALWAYS("lblDeclScopeAlways"), + NEVER("lblNever"), + ALWAYS("lblAlways"), STACK("lblDeclScopeStack"), TURN("lblDeclScopeTurn"); From cb74bc82d81b60abf2982d6f92729ac356389a1c Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 9 May 2026 18:49:22 +0200 Subject: [PATCH 29/36] Clean up --- .../forge/screens/match/views/VGameMenu.java | 3 +- .../gamemodes/match/AbstractGuiGame.java | 2 +- .../forge/gamemodes/match/HostedMatch.java | 6 +- .../gamemodes/match/YieldController.java | 70 ++++++++----------- .../match/input/InputPassPriority.java | 23 ++---- 5 files changed, 39 insertions(+), 65 deletions(-) diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index ebf4cffe45d..835889acbae 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -66,8 +66,7 @@ public void setVisible(boolean b0) { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldSettings"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { new VYieldOptions().show(); })); - boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); - String autoPassLabel = Forge.getLocalizer().getMessage(autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass"); + String autoPassLabel = Forge.getLocalizer().getMessage(FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS) ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass"); addItem(new FMenuItem(autoPassLabel, Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { YieldController.toggleAutoPassNoActions(MatchController.instance.getGameController()); })); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index af17176fe96..2f53e6125b3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -493,7 +493,7 @@ private String currentYieldMessage() { YieldController yielding = null; for (IGameController c : gameControllers.values()) { YieldController yc = c.getYieldController(); - if (yc != null && yc.shouldAutoYield()) { + if (yc.shouldAutoYield()) { yielding = yc; break; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index f619f554cbd..380c20216f5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -390,10 +390,10 @@ public void endCurrentGame() { humanController.getYieldController().clearAutoYields(); humanController.getYieldController().resetForNewGame(); - if (humanCount > 0) //conceded - humanController.getGui().afterGameEnd(); - else if (!GuiBase.getInterface().isLibgdxPort()||!isMatchOver) + //conceded + if (humanCount > 0 || !GuiBase.getInterface().isLibgdxPort() || !isMatchOver) { humanController.getGui().afterGameEnd(); + } humanController.getGui().updateDayTime(null); } humanControllers.clear(); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index b9ca9b7b52c..2e3e803b33e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -83,12 +83,26 @@ public YieldController(PlayerControllerHuman owner) { this.owner = owner; } + public synchronized String getStringPref(FPref pref) { + String override = prefOverrides.get(pref); + return override != null ? override : FModel.getPreferences().getPref(pref); + } + public boolean getBoolPref(FPref pref) { + return Boolean.parseBoolean(getStringPref(pref)); + } + public synchronized void setPref(FPref pref, String value) { + prefOverrides.put(pref, value); + } + + public DeclineScope getDeclineScope(FPref pref) { + return DeclineScope.fromPref(getStringPref(pref)); + } + public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { EnumSet set = skipPhases.get(turnPlayer); return set != null && set.contains(phase); } public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { - if (turnPlayer == null || phase == null) return; EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); if (skip) set.add(phase); else set.remove(phase); @@ -122,31 +136,6 @@ public synchronized void setAutoPassUntilEndOfTurn(boolean active) { } this.autoPassUntilEOT = active; } - - public synchronized String getStringPref(FPref pref) { - String override = prefOverrides.get(pref); - return override != null ? override : FModel.getPreferences().getPref(pref); - } - public boolean getBoolPref(FPref pref) { - return Boolean.parseBoolean(getStringPref(pref)); - } - public synchronized void setPref(FPref pref, String value) { - prefOverrides.put(pref, value); - } - - public DeclineScope getDeclineScope(FPref pref) { - return DeclineScope.fromPref(getStringPref(pref)); - } - - /** Read effective values from FModel for every synced yield FPref so the host's proxy can be seeded with the full set, not just prefs the user has touched this session. */ - public Map snapshotPrefs() { - ForgePreferences prefs = FModel.getPreferences(); - EnumMap out = new EnumMap<>(FPref.class); - for (FPref pref : SYNCED_PREFS) out.put(pref, prefs.getPref(pref)); - return out; - } - - // setMarker/clearMarker are mutated from EDT (right-click), Netty (wire receive), and game thread. public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; autoPassUntilStackEmpty = false; @@ -273,8 +262,7 @@ public void clearAutoYields() { localStore.clear(); return; } - boolean matchOver = owner.getGame() == null || owner.getGame().getView().isMatchOver(); - activeStore().onGameEnd(matchOver); + activeStore().onGameEnd(owner.getGame() == null || owner.getGame().getView().isMatchOver()); } /** Clear all transient yield state so it doesn't carry into the next game of the match. */ @@ -356,11 +344,14 @@ public YieldStateSnapshot buildClientSnapshot(Map else cardTriggers.put(e.getKey(), e.getValue()); } + EnumMap prefs = new EnumMap<>(FPref.class); + for (FPref pref : SYNCED_PREFS) prefs.put(pref, FModel.getPreferences().getPref(pref)); + return new YieldStateSnapshot( cardYields, abilityYields, cardTriggers, abilityTriggers, getDisableAutoYields(), getDisableAutoTriggers(), - skipPhases, snapshotPrefs()); + skipPhases, prefs); } /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ @@ -379,7 +370,7 @@ public void applyClientSeed(YieldStateSnapshot snap) { skipPhases.clear(); skipPhases.putAll(snap.skipPhases()); prefOverrides.clear(); - if (snap.prefOverrides() != null) prefOverrides.putAll(snap.prefOverrides()); + prefOverrides.putAll(snap.prefOverrides()); } /** @@ -472,10 +463,9 @@ public static boolean toggleAutoPassNoActions(IGameController ctrl) { public static void toggleAutoPassOrStopAll(IGameController ctrl) { if (ctrl == null) return; YieldController yc = ctrl.getYieldController(); - boolean transientActive = yc != null && yc.isYieldActive(); boolean apinaOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); - if (transientActive || apinaOn) { - if (yc != null) yc.clearActiveYieldAndDispatch(); + if (yc.isYieldActive() || apinaOn) { + yc.clearActiveYieldAndDispatch(); if (apinaOn) toggleAutoPassNoActions(ctrl); } else { toggleAutoPassNoActions(ctrl); @@ -529,18 +519,15 @@ public void onAttackersDeclared(CombatView combat) { } } - /** True when interrupt classifiers should run — an interruptible yield is active, or APINA is active with respects-interrupts on. */ private boolean shouldEvaluateInterrupts() { if (isInterruptibleYieldActive()) return true; - return getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) - && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS); + return getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS); } /** Apply an interrupt: clear any interruptible yield and pause APINA for one prompt. Either, both, or neither may apply. */ public synchronized void applyInterrupt() { if (isInterruptibleYieldActive()) clearActiveYieldAndDispatch(); - if (getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) - && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS)) { + if (getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS)) { autoPassInterrupted = true; } } @@ -552,10 +539,9 @@ && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS)) { private boolean autoPassInterrupted = false; public synchronized void onPriorityReceived(boolean stackNonEmpty) { - if (lastSeenStackNonEmpty && !stackNonEmpty) { - if (getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) == DeclineScope.STACK) { - declinedSuggestionTurn.remove(SuggestionType.STACK_YIELD); - } + if (lastSeenStackNonEmpty && !stackNonEmpty && + getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) == DeclineScope.STACK) { + declinedSuggestionTurn.remove(SuggestionType.STACK_YIELD); } lastSeenStackNonEmpty = stackNonEmpty; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java index edced7906aa..06abf028d11 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java @@ -179,10 +179,6 @@ private GameView getGameView() { return getController().getGui().getGameView(); } - private PlayerView getPlayerView() { - return getOwner(); - } - private boolean checkHasAvailableActions() { Player player = getController().getPlayer(); if (player == null) return false; @@ -198,25 +194,18 @@ private boolean shouldShowStackYieldPrompt() { return !checkHasAvailableActions(); } - /** Stack non-empty disqualifies; SUPPRESS_ON_OWN_TURN suppresses on own turn (after first round). */ - private boolean isValidSuggestionContext(GameView gv, PlayerView pv) { + private boolean shouldShowNoActionsPrompt() { + GameView gv = getGameView(); + PlayerView pv = getOwner(); + if (gv == null || pv == null) return false; FCollectionView stack = gv.getStack(); if (stack != null && !stack.isEmpty()) return false; PlayerView currentTurn = gv.getPlayerTurn(); if (currentTurn != null && currentTurn.equals(pv)) { // Always suppress on player's first turn (no lands/mana yet) - int numPlayers = gv.getPlayers().size(); - if (gv.getTurn() <= numPlayers) return false; + if (gv.getTurn() <= gv.getPlayers().size()) return false; if (getController().getYieldController().getBoolPref(FPref.YIELD_SUPPRESS_ON_OWN_TURN)) return false; } - return true; - } - - private boolean shouldShowNoActionsPrompt() { - GameView gv = getGameView(); - PlayerView pv = getPlayerView(); - if (gv == null || pv == null) return false; - if (!isValidSuggestionContext(gv, pv)) return false; return !checkHasAvailableActions(); } @@ -243,7 +232,7 @@ && getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACT pendingSuggestion = null; pendingSuggestionMessage = null; - PlayerView self = getPlayerView(); + PlayerView self = getOwner(); YieldController yc = getController().getYieldController(); if (accepted == SuggestionType.STACK_YIELD) { yc.setAutoPassUntilStackEmpty(true, true); From b03264c688ec9aee6d764cac4bd3817f35d82f44 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 10 May 2026 06:24:37 +0930 Subject: [PATCH 30/36] Drop unused YieldController.resetForNewGame; fix yield threading comment YieldController is reconstructed per game via LobbyPlayerHuman.createIngamePlayer, so HostedMatch.endCurrentGame was resetting PCHs about to be discarded. The comment above noteMayAutoPassResult was wrong: yieldJustEndedFlag is read on the EDT via didYieldJustEnd, and the synchronized writer/reader pair is what makes that safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/gamemodes/match/HostedMatch.java | 1 - .../forge/gamemodes/match/YieldController.java | 16 ---------------- .../java/forge/player/PlayerControllerHuman.java | 2 +- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 380c20216f5..64867031ac8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -388,7 +388,6 @@ public void endCurrentGame() { } humanController.getGui().setGameSpeed(PlaybackSpeed.NORMAL); humanController.getYieldController().clearAutoYields(); - humanController.getYieldController().resetForNewGame(); //conceded if (humanCount > 0 || !GuiBase.getInterface().isLibgdxPort() || !isMatchOver) { diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 2e3e803b33e..36e05e7c7e1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -265,22 +265,6 @@ public void clearAutoYields() { activeStore().onGameEnd(owner.getGame() == null || owner.getGame().getView().isMatchOver()); } - /** Clear all transient yield state so it doesn't carry into the next game of the match. */ - public synchronized void resetForNewGame() { - autoPassUntilEOT = false; - autoPassUntilStackEmpty = false; - stackYieldRespectsInterrupts = false; - autoPassUntilMarker = null; - hasLeftMarker = false; - activationOnMarker = false; - declinedSuggestionTurn.clear(); - lastSeenStackNonEmpty = false; - wasAutoPassingLastTick = false; - yieldJustEndedFlag = false; - autoPassInterrupted = false; - // prefOverrides intentionally kept — per-match, not per-game - } - public boolean getDisableAutoYields() { return activeStore().isDisabled(); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index ad176e13022..c16466e435a 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1507,7 +1507,7 @@ public List chooseSpellAbilityToPlay() { getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); } - // Game-thread only; no cross-thread races on the YIELD_SUPPRESS_AFTER_END flag + // yieldJustEndedFlag is read from the EDT (didYieldJustEnd); synchronized writer/reader pair handles visibility. boolean nowMayAutoPass = mayAutoPass(); yieldController.noteMayAutoPassResult(nowMayAutoPass); From e6cf292bed7dbf4dc687a763f770a64cf4e0c4ce Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Mon, 11 May 2026 17:47:17 +0200 Subject: [PATCH 31/36] Clean up --- forge-game/src/main/java/forge/game/player/PlayerView.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/forge-game/src/main/java/forge/game/player/PlayerView.java b/forge-game/src/main/java/forge/game/player/PlayerView.java index 8732900e115..b24158c5b6f 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -217,8 +217,6 @@ public void setHasAvailableActions(boolean value) { public int getAvatarLifeDifference() { return get(TrackableProperty.AvatarLifeDifference); - public boolean wasAvatarLifeChanged() { - return (int)get(TrackableProperty.AvatarLifeDifference) != 0; } public void setAvatarLifeDifference(final int val) { set(TrackableProperty.AvatarLifeDifference, val); From c3922a0abec001adab112b68a06a638e4a72a8fe Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Mon, 11 May 2026 22:11:36 +0200 Subject: [PATCH 32/36] Fix missing labels --- forge-gui/res/languages/en-US.properties | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 096707b7ea5..e7887d48233 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -16,7 +16,6 @@ lblAreYouSureYouWishExitForge=Are you sure you wish to exit Forge? lblOneOrMoreGamesActive=One or more games are currently active lblerrLoadingLayoutFile=Your %s layout file could not be read. It will be deleted after you press OK.\nThe game will proceed with default layout. lblLoadingQuest=Loading quest... - #CardType.java lblKindred=Kindred lblLand=Land @@ -41,7 +40,6 @@ lblLegendary=Legendary lblSnow=Snow lblOngoing=Ongoing lblWorld=World - #FScreen.java #translate lblHomeWithSpaces,lblDeckEditorWithSpaces need keep spaces in text lblHomeWithSpaces=Home @@ -684,7 +682,6 @@ lblGreen=Green lblRed=Red lblWhite=White lblColorless=Colorless -lblSnow=Snow lblIncludeArtifacts=Include Artifacts lblBalanced=Balanced lblTrueRandom=True Random @@ -826,7 +823,6 @@ lblCredits=Credits lblLife=Life lblWins=Wins lblLosses=Losses -lblWorld=World lblNone=None lblnextChallengeInWins0=Your exploits have been noticed. An opponent has challenged you. lblnextChallengeInWins1=A new challenge will be available after 1 more win. @@ -1651,6 +1647,8 @@ lblSuggestStackYield=When can''t respond to stack lblSuggestNoActions=When no actions available lblSuppressAfterYield=Suppress immediately after yield ends lblSuppressOnOwnTurn=Suppress on own turn +lblAccept=Accept +lblDecline=Decline lblDeclScopeStack=Once per stack lblDeclScopeTurn=Once per turn lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? @@ -2566,7 +2564,6 @@ lblZoomOrDetails=Zoom/Details #AdvancedSearch.java lblRulesText=Rules Text lblKeywords=Keywords -lblPlane=Plane lblRegion=Region lblColorCount=Color Count lblSupertype=Supertype @@ -2697,7 +2694,6 @@ lblReceivedAetherShardsForDuplicateCards=Received Aether Shards for Duplicate Ca lblReceivedBonusPlaneswalkEmblems=Received Bonus Planeswalk Emblems lblStartingBattle=Starting battle... lblChaosApproaching=Chaos approaching... -lblBattle=Battle #ConquestDeckEditor.java lblConquestCommander=Conquest Commander #ConquestPlaneSelector.java @@ -3496,4 +3492,3 @@ lblRemoveUnsupportedCard=Remove unsupported card lblRemoveAllUnsupportedCards=Unsupported cards have been removed from your inventory. lbldisableCrackedItems=Disable the possibility of your items breaking after losing a boss fight. lblusepricelist=Use the (currently experimental) price generation based on a set cardlist. - From f8da52f12fe47af42c08e5cc8f66fafab996649c Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Mon, 11 May 2026 23:46:28 +0200 Subject: [PATCH 33/36] Clean up --- .../java/forge/gamemodes/match/YieldController.java | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 36e05e7c7e1..93adfbc24a8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -140,10 +140,6 @@ public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boole autoPassUntilEOT = false; autoPassUntilStackEmpty = false; stackYieldRespectsInterrupts = false; - if (phaseOwner == null || phase == null) { - clearMarker(); - return; - } autoPassUntilMarker = new YieldMarker(phaseOwner, phase); // Activating at-or-past target on the owner's current turn must wait for next turn's // occurrence; otherwise pastTarget would fire and clear the marker on the same turn. @@ -584,17 +580,14 @@ private static boolean isBeingAttacked(CombatView combatView, PlayerView player) if (combatView == null) return false; if (!combatView.getAttackersOf(player).isEmpty()) return true; for (forge.game.GameEntityView defender : combatView.getDefenders()) { - if (defender instanceof CardView cardDefender) { - PlayerView controller = cardDefender.getController(); - if (controller != null && controller.equals(player)) { - if (!combatView.getAttackersOf(defender).isEmpty()) return true; - } + if (defender instanceof CardView cardDefender && player.equals(cardDefender.getController()) && + !combatView.getAttackersOf(defender).isEmpty()) { + return true; } } return false; } - /** Recurses into sub-instances (e.g. Oona, where targeting is in a sub-ability). */ private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView player) { FCollectionView targetPlayers = si.getTargetPlayers(); if (targetPlayers != null) { From 9a8db62eb500b9556816d99b9c706912c58a3b74 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 13 May 2026 21:31:59 +0930 Subject: [PATCH 34/36] Clear host UI when auto-passing through chooseSpellAbilityToPlay When APINA (or skip-phase / per-card auto-yield) makes mayAutoPass true, chooseSpellAbilityToPlay returned null without creating a new InputPassPriority. The local GUI was left with the prior IPP's prompt text (turn/phase/priority line) and enabled OK/End Turn buttons. Call getGui().awaitNextInput() before each return-null branch so the "Waiting for opponent" hand-off fires, repainting the prompt and disabling the buttons. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/forge/player/PlayerControllerHuman.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index b28a3dee5f2..8c13cc4070b 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1534,12 +1534,14 @@ public List chooseSpellAbilityToPlay() { e.printStackTrace(); } } + getGui().awaitNextInput(); netLog.trace("Returning null (mayAutoPass) for player {}", player.getName()); return null; } if (stack.isEmpty()) { if (isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), getGame().getPhaseHandler().getPhase())) { + getGui().awaitNextInput(); netLog.trace("Returning null (skipPhase) for player {}", player.getName()); return null; // avoid prompt for input if stack is empty and // player is set to skip the current phase @@ -1553,6 +1555,7 @@ public List chooseSpellAbilityToPlay() { } catch (final InterruptedException e) { e.printStackTrace(); } + getGui().awaitNextInput(); netLog.trace("Returning null (autoYield) for player {}", player.getName()); return null; } From 4608f04773b033c08416ca3740798ef573de31eb Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 13 May 2026 21:38:00 +0200 Subject: [PATCH 35/36] Clean up --- .../main/java/forge/ai/ability/ChangeZoneAi.java | 13 ++++--------- .../src/forge/screens/constructed/LobbyScreen.java | 11 ++++------- .../java/forge/gamemodes/match/YieldController.java | 1 - 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index 9c21288d3a6..3424428d149 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1556,7 +1556,7 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } // Tutor for the first key card in the list, since the list should be in priority order - for(String keyName : keyCards) { + for (String keyName : keyCards) { CardCollection withKeyCard = CardLists.filter(fetchList, CardPredicates.nameEquals(keyName)); if (withKeyCard.isEmpty()) { continue; @@ -1567,14 +1567,9 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List // Does AI need a land? // The logic here seems wrong if the decider isn't the same as the player CardCollectionView hand = decider.getCardsIn(ZoneType.Hand); - if (!hand.anyMatch(CardPredicates.LANDS) && CardLists.count(decider.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS) < 4) { - boolean canCastSomething = false; - for (Card cardInHand : hand) { - canCastSomething = canCastSomething || ComputerUtilMana.hasEnoughManaSourcesToCast(cardInHand.getFirstSpellAbility(), decider); - } - if (!canCastSomething) { - c = basicManaFixing(decider, fetchList); - } + if (!hand.anyMatch(CardPredicates.LANDS) && CardLists.count(decider.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS) < 4 && + !hand.anyMatch(crd -> ComputerUtilMana.hasEnoughManaSourcesToCast(crd.getFirstSpellAbility(), decider))) { + c = basicManaFixing(decider, fetchList); } if (c == null) { if (fetchList.allMatch(CardPredicates.LANDS)) { diff --git a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java index 836180ddadc..05b43c4b45c 100644 --- a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java +++ b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java @@ -818,7 +818,7 @@ void updateSleeve(final int index, final int sleeveIndex) { } void setReady(final int index, final boolean ready) { - if (lobby.isAllowNetworking()){ + if (lobby.isAllowNetworking()) { updateDeck(index); fireReady(index, ready); return; @@ -843,11 +843,8 @@ void setDevMode(final int index) { int playerCount = lobby.getNumberOfSlots(); // clear ready for everyone for (int i = 0; i < playerCount; i++) { - final PlayerPanel panel = playerPanels.get(i); - final boolean wasReady = panel.isReady(); - panel.setIsReady(false); - if (wasReady && playerChangeListener != null) { - playerChangeListener.update(i, UpdateLobbyPlayerEvent.isReadyUpdate(false)); + if (playerPanels.get(i).isReady()) { + fireReady(i, false); } } } @@ -856,7 +853,7 @@ void firePlayerChangeListener(final int index) { playerChangeListener.update(index, getSlot(index)); } } - void fireReady(final int index, boolean ready){ + void fireReady(final int index, boolean ready) { playerPanels.get(index).setIsReady(ready); if (playerChangeListener != null) { playerChangeListener.update(index, UpdateLobbyPlayerEvent.isReadyUpdate(ready)); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 93adfbc24a8..3f29e89e175 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -605,7 +605,6 @@ private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView pl return subInstance != null && targetsPlayerOrPermanents(subInstance, player); } - /** Recurses into sub-instances for modal spells like Farewell. */ private static boolean isMassRemoval(SpellAbilityStackInstance si) { ApiType api = si.getSpellAbility().getApi(); if (api == ApiType.DestroyAll From 9a07de03709992330b84382a25ec0eedf8e9b3ce Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 14 May 2026 06:33:19 +0930 Subject: [PATCH 36/36] Add Enable auto-pass checkbox to desktop Game menu The dock button shouldn't be the only UI entry point for toggling auto-pass. Adds an "Enable auto-pass" checkbox under Yield Settings, displaying the existing P-key accelerator. Clicking toggles the same APINA pref as the dock button and refreshes its state; a MenuListener re-reads the pref on menu open so it stays in sync after P-key or dock-button toggles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/screens/match/menus/GameMenu.java | 27 ++++++++++++++++++- forge-gui/res/languages/en-US.properties | 1 + 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 94c26ea600c..65eda0b6063 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -5,9 +5,12 @@ import javax.swing.ButtonGroup; import javax.swing.JMenu; +import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; +import javax.swing.event.MenuEvent; +import javax.swing.event.MenuListener; import com.google.common.primitives.Ints; @@ -60,8 +63,17 @@ public JMenu getMenu() { menu.add(getMenuItem_SeparateCombatStacks()); menu.add(getMenuItem_AutoYieldsAndTriggers()); menu.add(getMenuItem_YieldSettings()); + final SkinnedCheckBoxMenuItem autoPassItem = getMenuItem_AutoPass(); + menu.add(autoPassItem); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); + menu.addMenuListener(new MenuListener() { + @Override public void menuSelected(final MenuEvent e) { + autoPassItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + } + @Override public void menuDeselected(final MenuEvent e) {} + @Override public void menuCanceled(final MenuEvent e) {} + }); return menu; } @@ -116,7 +128,7 @@ private ActionListener getEndTurnAction() { } /** Sets a menu item's accelerator display from a shortcut preference. */ - private static void setAcceleratorFromPref(final SkinnedMenuItem menuItem, final FPref pref) { + private static void setAcceleratorFromPref(final JMenuItem menuItem, final FPref pref) { final KeyStroke ks = KeyboardShortcuts.getKeyStrokeForPref(pref); if (ks != null) { menuItem.setAccelerator(ks); @@ -194,6 +206,19 @@ private SkinnedMenuItem getMenuItem_YieldSettings() { return menuItem; } + private SkinnedCheckBoxMenuItem getMenuItem_AutoPass() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("lblEnableAutoPass")); + setAcceleratorFromPref(menuItem, FPref.SHORTCUT_YIELD_AUTO_PASS); + menuItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + menuItem.addActionListener(e -> { + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + matchUI.getCDock().refreshAutoPassToggled(); + menuItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + }); + return menuItem; + } + private SkinnedMenuItem getMenuItem_ViewDeckList() { final Localizer localizer = Localizer.getInstance(); final SkinnedMenuItem menuItem = new SkinnedMenuItem(localizer.getMessage("lblDeckList")); diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index f1928bacaeb..b3672966a97 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1623,6 +1623,7 @@ lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yi lblYieldToStack=Yield to stack lblYieldToEntireStack=Resolve entire stack lblYieldSettings=Yield Settings +lblEnableAutoPass=Enable auto-pass lblYieldBtnAutoPass=Auto-Pass: OFF lblYieldBtnAutoPassOn=Auto-Pass: ON lblYieldBtnAutoPassTooltip=Auto-pass: Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare.