From 9e65d298f8086872980ed3979bba5d444682a7be Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Wed, 28 Jan 2026 19:52:15 +1030 Subject: [PATCH 01/33] Add experimental yield system for reduced multiplayer micromanagement Adds feature-gated yield options to reduce clicking in multiplayer games: - 3 yield modes: Until Stack Clears, Until End of Turn, Until Your Next Turn - Right-click End Turn button for yield menu - Keyboard shortcuts (Ctrl+Shift+S, Ctrl+Shift+N) - Smart yield suggestions when player can't respond - Configurable interrupt conditions via Game menu - Master toggle in preferences (default OFF) Co-Authored-By: Claude Opus 4.5 --- .../java/forge/control/KeyboardShortcuts.java | 26 +++ .../forge/screens/match/menus/GameMenu.java | 37 ++++ .../forge/screens/match/views/VPrompt.java | 55 ++++++ forge-gui/res/languages/en-US.properties | 24 +++ .../gamemodes/match/AbstractGuiGame.java | 184 +++++++++++++++++- .../java/forge/gamemodes/match/YieldMode.java | 39 ++++ .../match/input/InputPassPriority.java | 173 ++++++++++++++++ .../forge/gamemodes/net/ProtocolMethod.java | 2 + .../net/client/NetGameController.java | 10 + .../java/forge/gui/interfaces/IGuiGame.java | 10 + .../forge/interfaces/IGameController.java | 5 + .../properties/ForgePreferences.java | 14 ++ .../forge/player/PlayerControllerHuman.java | 12 ++ 13 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java 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 8b2de20b8de..25ff1306893 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -113,6 +113,30 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Yield until stack clears (experimental). */ + final Action actYieldUntilStackClears = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.getGameController().yieldUntilStackClears(); + } + }; + + /** Yield until your next turn (experimental, 3+ players only). */ + final Action actYieldUntilYourNextTurn = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + if (matchUI.getPlayerCount() >= 3) { + matchUI.getGameController().yieldUntilYourNextTurn(); + } + } + }; + /** Alpha Strike. */ final Action actAllAttack = new AbstractAction() { @Override @@ -208,6 +232,8 @@ public void actionPerformed(ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWDEV, localizer.getMessage("lblSHORTCUT_SHOWDEV"), actShowDev, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CONCEDE, localizer.getMessage("lblSHORTCUT_CONCEDE"), actConcede, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ENDTURN, localizer.getMessage("lblSHORTCUT_ENDTURN"), actEndTurn, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS"), actYieldUntilStackClears, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN"), actYieldUntilYourNextTurn, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ALPHASTRIKE, localizer.getMessage("lblSHORTCUT_ALPHASTRIKE"), actAllAttack, am, im)); 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)); 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 3d7db593366..22f02ff2cdc 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 @@ -4,6 +4,7 @@ import java.awt.event.KeyEvent; import javax.swing.ButtonGroup; +import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JPopupMenu; @@ -50,6 +51,9 @@ public JMenu getMenu() { menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); menu.add(getMenuItem_AutoYields()); + if (prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + menu.add(getYieldOptionsMenu()); + } menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); menu.addSeparator(); @@ -204,4 +208,37 @@ private SkinnedMenuItem getMenuItem_ViewDeckList() { private ActionListener getViewDeckListAction() { return e -> matchUI.viewDeckList(); } + + private JMenu getYieldOptionsMenu() { + final Localizer localizer = Localizer.getInstance(); + final JMenu yieldMenu = new JMenu(localizer.getMessage("lblYieldOptions")); + + // Sub-menu 1: Interrupt Settings + final JMenu interruptMenu = new JMenu(localizer.getMessage("lblInterruptSettings")); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnBlockers"), FPref.YIELD_INTERRUPT_ON_BLOCKERS)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnCombat"), FPref.YIELD_INTERRUPT_ON_COMBAT)); + yieldMenu.add(interruptMenu); + + // Sub-menu 2: Automatic Suggestions + final JMenu suggestionsMenu = new JMenu(localizer.getMessage("lblAutomaticSuggestions")); + suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestStackYield"), FPref.YIELD_SUGGEST_STACK_YIELD)); + suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestNoMana"), FPref.YIELD_SUGGEST_NO_MANA)); + suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestNoActions"), FPref.YIELD_SUGGEST_NO_ACTIONS)); + yieldMenu.add(suggestionsMenu); + + return yieldMenu; + } + + private JCheckBoxMenuItem createYieldCheckbox(String label, FPref pref) { + final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label); + item.setSelected(prefs.getPrefBoolean(pref)); + item.addActionListener(e -> { + prefs.setPref(pref, item.isSelected()); + prefs.save(); + }); + return item; + } } 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 2fb0f440829..aa0bd334e0b 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 @@ -25,9 +25,12 @@ import java.awt.event.MouseEvent; import javax.swing.JLabel; +import javax.swing.JMenuItem; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.ScrollPaneConstants; import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; import forge.game.card.CardView; import forge.gui.framework.DragCell; @@ -100,6 +103,16 @@ public VPrompt(final CPrompt controller) { btnOK.addKeyListener(buttonKeyAdapter); btnCancel.addKeyListener(buttonKeyAdapter); + // Add right-click menu for yield options (experimental feature) + btnCancel.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (SwingUtilities.isRightMouseButton(e) && isYieldExperimentalEnabled()) { + showYieldOptionsMenu(e); + } + } + }); + tarMessage.setForeground(FSkin.getColor(FSkin.Colors.CLR_TEXT)); tarMessage.setMargin(new Insets(3, 3, 3, 3)); tarMessage.getAccessibleContext().setAccessibleName("Prompt"); @@ -205,4 +218,46 @@ public FHtmlViewer getTarMessage() { public JLabel getLblGames() { return this.lblGames; } + + // Yield options menu support (experimental feature) + + private boolean isYieldExperimentalEnabled() { + return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + private void showYieldOptionsMenu(MouseEvent e) { + JPopupMenu menu = new JPopupMenu(); + Localizer loc = Localizer.getInstance(); + + // Until Stack Clears + JMenuItem stackItem = new JMenuItem(loc.getMessage("lblYieldUntilStackClears")); + stackItem.addActionListener(evt -> { + if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().yieldUntilStackClears(); + } + }); + menu.add(stackItem); + + // Until End of Turn + JMenuItem turnItem = new JMenuItem(loc.getMessage("lblYieldUntilEndOfTurn")); + turnItem.addActionListener(evt -> { + if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().passPriorityUntilEndOfTurn(); + } + }); + menu.add(turnItem); + + // Until Your Next Turn (only in 3+ player games) + if (controller.getMatchUI() != null && controller.getMatchUI().getPlayerCount() >= 3) { + JMenuItem yourNextTurnItem = new JMenuItem(loc.getMessage("lblYieldUntilYourNextTurn")); + yourNextTurnItem.addActionListener(evt -> { + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().yieldUntilYourNextTurn(); + } + }); + menu.add(yourNextTurnItem); + } + + menu.show(btnCancel, e.getX(), e.getY()); + } } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 72d80650404..28c10960578 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1522,6 +1522,30 @@ lblCloseGameSpectator=This will close this game and you will not be able to resu lblCloseGame=Close Game? lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. +lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. +lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this yield to take an action. +lblYieldUntilStackClears=Yield Until Stack Clears +lblYieldUntilEndOfTurn=Yield Until End of Turn +lblYieldUntilYourNextTurn=Yield Until Your Next Turn +lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? +lblNoManaAvailableYieldPrompt=You have no mana available. Yield until your turn? +lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? +lblYieldSuggestion=Yield Suggestion +lblAccept=Accept +lblDecline=Decline +lblYieldOptions=Yield Options +lblInterruptSettings=Interrupt Settings +lblAutomaticSuggestions=Automatic Suggestions +lblInterruptOnAttackers=When attackers declared against you +lblInterruptOnBlockers=When you can declare blockers +lblInterruptOnTargeting=When targeted by spell or ability +lblInterruptOnOpponentSpell=When opponent casts a spell +lblInterruptOnCombat=At beginning of combat +lblSuggestStackYield=When can't respond to stack +lblSuggestNoMana=When no mana available +lblSuggestNoActions=When no actions available +lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS=Yield Until Stack Clears +lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN=Yield Until Your Next Turn 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 333981e1311..307bddf0744 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -416,6 +416,10 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); + // Extended yield mode tracking (experimental feature) + private final Map playerYieldMode = Maps.newHashMap(); + private final Map yieldTurnOwner = Maps.newHashMap(); + /** * Automatically pass priority until reaching the Cleanup phase of the * current turn. @@ -499,13 +503,183 @@ public final void cancelAwaitNextInput() { @Override public final void updateAutoPassPrompt() { - if (!autoPassUntilEndOfTurn.isEmpty()) { - //allow user to cancel auto-pass - cancelAwaitNextInput(); //don't overwrite prompt with awaiting opponent - showPromptMessage(getCurrentPlayer(), Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn")); - updateButtons(getCurrentPlayer(), false, true, false); + PlayerView player = getCurrentPlayer(); + + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + cancelAwaitNextInput(); + showPromptMessage(player, Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn")); + updateButtons(player, false, true, false); + return; + } + + // Check experimental yield modes + YieldMode mode = playerYieldMode.get(player); + if (mode != null && mode != YieldMode.NONE) { + cancelAwaitNextInput(); + Localizer loc = Localizer.getInstance(); + String message = switch (mode) { + case UNTIL_STACK_CLEARS -> loc.getMessage("lblYieldingUntilStackClears"); + case UNTIL_END_OF_TURN -> loc.getMessage("lblYieldingUntilEndOfTurn"); + case UNTIL_YOUR_NEXT_TURN -> loc.getMessage("lblYieldingUntilYourNextTurn"); + default -> ""; + }; + showPromptMessage(player, message); + updateButtons(player, false, true, false); + } + } + + // Extended yield mode methods (experimental feature) + @Override + public final void setYieldMode(final PlayerView player, final YieldMode mode) { + if (!isYieldExperimentalEnabled()) { + // Fall back to legacy behavior for UNTIL_END_OF_TURN + if (mode == YieldMode.UNTIL_END_OF_TURN) { + autoPassUntilEndOfTurn.add(player); + updateAutoPassPrompt(); + } + return; } + + if (mode == YieldMode.NONE) { + clearYieldMode(player); + return; + } + + playerYieldMode.put(player, mode); + if (getGameView() != null && getGameView().getGame() != null) { + yieldTurnOwner.put(player, getGameView().getGame().getPhaseHandler().getPlayerTurn()); + } + updateAutoPassPrompt(); + } + + @Override + public final void clearYieldMode(final PlayerView player) { + playerYieldMode.remove(player); + yieldTurnOwner.remove(player); + autoPassUntilEndOfTurn.remove(player); // Legacy compatibility + + showPromptMessage(player, ""); + updateButtons(player, false, false, false); + awaitNextInput(); } + + @Override + public final boolean shouldAutoYieldForPlayer(final PlayerView player) { + // Check legacy system first + if (autoPassUntilEndOfTurn.contains(player)) { + return true; + } + + if (!isYieldExperimentalEnabled()) { + return false; + } + + YieldMode mode = playerYieldMode.get(player); + if (mode == null || mode == YieldMode.NONE) { + return false; + } + + // Check interrupt conditions + if (shouldInterruptYield(player)) { + clearYieldMode(player); + return false; + } + + if (getGameView() == null || getGameView().getGame() == null) { + return false; + } + + forge.game.Game game = getGameView().getGame(); + forge.game.phase.PhaseHandler ph = game.getPhaseHandler(); + + return switch (mode) { + case UNTIL_STACK_CLEARS -> !game.getStack().isEmpty(); + case UNTIL_END_OF_TURN -> yieldTurnOwner.get(player) != null && yieldTurnOwner.get(player).equals(ph.getPlayerTurn()); + case UNTIL_YOUR_NEXT_TURN -> { + forge.game.player.Player playerObj = game.getPlayer(player); + yield !ph.getPlayerTurn().equals(playerObj); + } + default -> false; + }; + } + + private boolean shouldInterruptYield(final PlayerView player) { + if (getGameView() == null || getGameView().getGame() == null) { + return false; + } + + forge.game.Game game = getGameView().getGame(); + forge.game.player.Player p = game.getPlayer(player); + ForgePreferences prefs = FModel.getPreferences(); + forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { + if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && + game.getCombat() != null && game.getCombat().getDefenders().contains(p)) { + return true; + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { + if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && + game.getCombat() != null && game.getCombat().getDefenders().contains(p)) { + return true; + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TARGETING)) { + for (forge.game.spellability.StackItemView si : getGameView().getStack()) { + if (targetsPlayerOrPermanents(si, p)) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + if (!game.getStack().isEmpty()) { + forge.game.spellability.SpellAbility topSa = game.getStack().peekAbility(); + if (topSa != null && !topSa.getActivatingPlayer().equals(p)) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { + if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { + return true; + } + } + + return false; + } + + private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { + PlayerView pv = p.getView(); + + for (PlayerView target : si.getTargetPlayers()) { + if (target.equals(pv)) return true; + } + + for (CardView target : si.getTargetCards()) { + if (target.getController() != null && target.getController().equals(pv)) { + return true; + } + } + return false; + } + + private boolean isYieldExperimentalEnabled() { + return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + @Override + public int getPlayerCount() { + return getGameView() != null && getGameView().getGame() != null + ? getGameView().getGame().getPlayers().size() + : 0; + } + // End auto-yield/input code // Abilities to auto-yield to diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java new file mode 100644 index 00000000000..c9581875420 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java @@ -0,0 +1,39 @@ +/* + * 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.gamemodes.match; + +/** + * Yield modes for extended auto-pass functionality. + * Used when experimental yield options are enabled. + */ +public enum YieldMode { + NONE("No auto-yield"), + UNTIL_STACK_CLEARS("Yield until stack clears"), + UNTIL_END_OF_TURN("Yield until end of turn"), + UNTIL_YOUR_NEXT_TURN("Yield until your next turn"); + + private final String description; + + YieldMode(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} 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 58dc1fb71ff..01ea794b86d 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 @@ -22,6 +22,9 @@ import forge.game.player.Player; import forge.game.player.actions.PassPriorityAction; import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldMode; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.GamePlayerUtil; @@ -54,6 +57,36 @@ public InputPassPriority(final PlayerControllerHuman controller) { /** {@inheritDoc} */ @Override public final void showMessage() { + // Check if experimental yield features are enabled and show smart suggestions + if (isExperimentalYieldEnabled()) { + ForgePreferences prefs = FModel.getPreferences(); + + // Suggestion 1: Stack items but can't respond + if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_STACK_YIELD) && shouldShowStackYieldPrompt()) { + if (showStackYieldPrompt()) { + getController().getGui().setYieldMode(getOwner(), YieldMode.UNTIL_STACK_CLEARS); + stop(); + return; + } + } + // Suggestion 2: Has cards but no mana + else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_MANA) && shouldShowNoManaPrompt()) { + if (showNoManaPrompt()) { + getController().getGui().setYieldMode(getOwner(), getDefaultYieldMode()); + stop(); + return; + } + } + // Suggestion 3: No available actions (empty hand, no abilities) + else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) && shouldShowNoActionsPrompt()) { + if (showNoActionsPrompt()) { + getController().getGui().setYieldMode(getOwner(), getDefaultYieldMode()); + stop(); + return; + } + } + } + showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); @@ -176,4 +209,144 @@ public boolean selectAbility(final SpellAbility ab) { } return false; } + + // Smart yield suggestion helper methods + + private boolean isExperimentalYieldEnabled() { + return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + private YieldMode getDefaultYieldMode() { + return getController().getGame().getPlayers().size() >= 3 + ? YieldMode.UNTIL_YOUR_NEXT_TURN + : YieldMode.UNTIL_END_OF_TURN; + } + + private boolean shouldShowStackYieldPrompt() { + Game game = getController().getGame(); + Player player = getController().getPlayer(); + + if (game.getStack().isEmpty()) { + return false; + } + + return !canRespondToStack(game, player); + } + + private boolean canRespondToStack(Game game, Player player) { + // Check hand for playable spells (getAllPossibleAbilities already filters by timing) + for (Card card : player.getCardsIn(ZoneType.Hand)) { + if (!card.getAllPossibleAbilities(player, true).isEmpty()) { + return true; + } + } + + // Check battlefield for activatable abilities (excluding mana abilities) + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (!sa.isManaAbility()) { + return true; + } + } + } + + return false; + } + + private boolean showStackYieldPrompt() { + Localizer loc = Localizer.getInstance(); + return getController().getGui().showConfirmDialog( + loc.getMessage("lblCannotRespondToStackYieldPrompt"), + loc.getMessage("lblYieldSuggestion"), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline") + ); + } + + private boolean shouldShowNoManaPrompt() { + Game game = getController().getGame(); + Player player = getController().getPlayer(); + + if (!game.getStack().isEmpty()) { + return false; + } + + if (game.getPhaseHandler().getPlayerTurn().equals(player)) { + return false; + } + + if (player.getCardsIn(ZoneType.Hand).isEmpty()) { + return false; + } + + return !hasManaAvailable(player); + } + + private boolean hasManaAvailable(Player player) { + if (player.getManaPool().totalMana() > 0) { + return true; + } + + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + if (card.isUntapped()) { + for (SpellAbility sa : card.getManaAbilities()) { + if (sa.canPlay()) { + return true; + } + } + } + } + + return false; + } + + private boolean showNoManaPrompt() { + Localizer loc = Localizer.getInstance(); + return getController().getGui().showConfirmDialog( + loc.getMessage("lblNoManaAvailableYieldPrompt"), + loc.getMessage("lblYieldSuggestion"), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline") + ); + } + + private boolean shouldShowNoActionsPrompt() { + Player player = getController().getPlayer(); + Game game = getController().getGame(); + + if (!game.getStack().isEmpty()) { + return false; + } + + if (game.getPhaseHandler().getPlayerTurn().equals(player)) { + return false; + } + + return !hasAvailableActions(game, player); + } + + private boolean hasAvailableActions(Game game, Player player) { + if (!player.getCardsIn(ZoneType.Hand).isEmpty()) { + return true; + } + + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllSpellAbilities()) { + if (sa.canPlay() && !sa.isTrigger() && !sa.isManaAbility()) { + return true; + } + } + } + return false; + } + + private boolean showNoActionsPrompt() { + Localizer loc = Localizer.getInstance(); + return getController().getGui().showConfirmDialog( + loc.getMessage("lblNoActionsAvailableYieldPrompt"), + loc.getMessage("lblYieldSuggestion"), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline") + ); + } } 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 fb16741142e..c4ccac66c7f 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -92,6 +92,8 @@ public enum ProtocolMethod { selectButtonCancel (Mode.CLIENT, Void.TYPE), selectAbility (Mode.CLIENT, Void.TYPE, SpellAbilityView.class), passPriorityUntilEndOfTurn(Mode.CLIENT, Void.TYPE), + yieldUntilStackClears (Mode.CLIENT, Void.TYPE), + yieldUntilYourNextTurn (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 57bec3d0aee..a75b18f75f1 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 @@ -73,6 +73,16 @@ public void passPriorityUntilEndOfTurn() { send(ProtocolMethod.passPriorityUntilEndOfTurn); } + @Override + public void yieldUntilStackClears() { + send(ProtocolMethod.yieldUntilStackClears); + } + + @Override + public void yieldUntilYourNextTurn() { + send(ProtocolMethod.yieldUntilYourNextTurn); + } + @Override public void passPriority() { send(ProtocolMethod.passPriority); diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index b56468dcc52..8a704e55095 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -10,6 +10,7 @@ import forge.game.event.GameEventSpellAbilityCast; import forge.game.event.GameEventSpellRemovedFromStack; import forge.game.phase.PhaseType; +import forge.gamemodes.match.YieldMode; import forge.game.player.DelayedReveal; import forge.game.player.IHasIcon; import forge.game.player.PlayerView; @@ -261,6 +262,15 @@ public interface IGuiGame { void updateAutoPassPrompt(); + // Extended yield mode methods (experimental feature) + void setYieldMode(PlayerView player, YieldMode mode); + + void clearYieldMode(PlayerView player); + + boolean shouldAutoYieldForPlayer(PlayerView player); + + int getPlayerCount(); + boolean shouldAutoYield(String key); void setShouldAutoYield(String key, boolean autoYield); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 4367ca77bb2..9be0962964c 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -28,6 +28,11 @@ public interface IGameController { void passPriorityUntilEndOfTurn(); + // Extended yield methods (experimental feature) + void yieldUntilStackClears(); + + void yieldUntilYourNextTurn(); + void selectPlayer(PlayerView playerView, ITriggerEvent triggerEvent); boolean selectCard(CardView cardView, List otherCardViewsToSelect, ITriggerEvent triggerEvent); 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 baf8d3a95ef..77ba1f6a256 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -134,6 +134,18 @@ public enum FPref implements PreferencesStore.IPref { UI_HIDE_GAME_TABS ("false"), // Visibility of tabs in match screen. UI_CLOSE_ACTION ("NONE"), UI_MANA_LOST_PROMPT ("false"), // Prompt on losing mana when passing priority + + // Experimental yield options (feature-gated) + YIELD_EXPERIMENTAL_OPTIONS("false"), + YIELD_SUGGEST_STACK_YIELD("true"), + YIELD_SUGGEST_NO_MANA("true"), + YIELD_SUGGEST_NO_ACTIONS("true"), + YIELD_INTERRUPT_ON_ATTACKERS("true"), + YIELD_INTERRUPT_ON_BLOCKERS("true"), + YIELD_INTERRUPT_ON_TARGETING("true"), + YIELD_INTERRUPT_ON_OPPONENT_SPELL("false"), + YIELD_INTERRUPT_ON_COMBAT("false"), + UI_STACK_EFFECT_NOTIFICATION_POLICY ("Never"), UI_LAND_PLAYED_NOTIFICATION_POLICY ("Never"), UI_PAUSE_WHILE_MINIMIZED("false"), @@ -286,6 +298,8 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), + SHORTCUT_YIELD_UNTIL_STACK_CLEARS("17 16 83"), // Ctrl+Shift+S + SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78"), // Ctrl+Shift+N LAST_IMPORTED_CUBE_ID(""); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 7aef50cf68e..3fd9512343e 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3255,6 +3255,18 @@ public void autoPassCancel() { getGui().autoPassCancel(getLocalPlayerView()); } + public void yieldUntilStackClears() { + getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_STACK_CLEARS); + } + + public void yieldUntilYourNextTurn() { + getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_YOUR_NEXT_TURN); + } + + public int getPlayerCount() { + return getGui().getPlayerCount(); + } + @Override public void awaitNextInput() { getGui().awaitNextInput(); From b47d81a6c417698bacdeb5d63c6aa08a781137ef Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Wed, 28 Jan 2026 20:39:15 +1030 Subject: [PATCH 02/33] Add preferences GUI toggle for experimental yield options - Add checkbox in Gameplay section (after Auto-Yield) - Hide yield keyboard shortcuts when feature disabled - Update description to cover all features Co-Authored-By: Claude Opus 4.5 --- .../screens/home/settings/CSubmenuPreferences.java | 1 + .../screens/home/settings/VSubmenuPreferences.java | 14 ++++++++++++++ forge-gui/res/languages/en-US.properties | 2 ++ 3 files changed, 17 insertions(+) diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 27c185c3ae8..df173d979ad 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -156,6 +156,7 @@ public void initialize() { lstControls.add(Pair.of(view.getCbTokensInSeparateRow(), FPref.UI_TOKENS_IN_SEPARATE_ROW)); lstControls.add(Pair.of(view.getCbStackCreatures(), FPref.UI_STACK_CREATURES)); lstControls.add(Pair.of(view.getCbManaLostPrompt(), FPref.UI_MANA_LOST_PROMPT)); + lstControls.add(Pair.of(view.getCbYieldExperimentalOptions(), FPref.YIELD_EXPERIMENTAL_OPTIONS)); lstControls.add(Pair.of(view.getCbEscapeEndsTurn(), FPref.UI_ALLOW_ESC_TO_END_TURN)); lstControls.add(Pair.of(view.getCbDetailedPaymentDesc(), FPref.UI_DETAILED_SPELLDESC_IN_PROMPT)); lstControls.add(Pair.of(view.getCbGrayText(), FPref.UI_GRAY_INACTIVE_TEXT)); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index 2e779931550..67fef6c7bf3 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -72,6 +72,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbManaBurn = new OptionsCheckBox(localizer.getMessage("cbManaBurn")); private final JCheckBox cbOrderCombatants = new OptionsCheckBox(localizer.getMessage("cbOrderCombatants")); private final JCheckBox cbManaLostPrompt = new OptionsCheckBox(localizer.getMessage("cbManaLostPrompt")); + private final JCheckBox cbYieldExperimentalOptions = new OptionsCheckBox(localizer.getMessage("cbYieldExperimentalOptions")); private final JCheckBox cbDevMode = new OptionsCheckBox(localizer.getMessage("cbDevMode")); private final JCheckBox cbLoadCardsLazily = new OptionsCheckBox(localizer.getMessage("cbLoadCardsLazily")); private final JCheckBox cbLoadArchivedFormats = new OptionsCheckBox(localizer.getMessage("cbLoadArchivedFormats")); @@ -284,6 +285,9 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbpAutoYieldMode, comboBoxConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlpAutoYieldMode")), descriptionConstraints); + pnlPrefs.add(cbYieldExperimentalOptions, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlYieldExperimentalOptions")), descriptionConstraints); + //Server Preferences pnlPrefs.add(new SectionLabel(localizer.getMessage("ServerPreferences")), sectionConstraints); @@ -467,8 +471,14 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(new SectionLabel(localizer.getMessage("KeyboardShortcuts")), sectionConstraints); final List shortcuts = KeyboardShortcuts.getKeyboardShortcuts(); + final boolean yieldExperimentalEnabled = FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); for (final Shortcut s : shortcuts) { + // Skip yield shortcuts if experimental options not enabled + if (!yieldExperimentalEnabled && (s.getPrefKey() == FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS + || s.getPrefKey() == FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN)) { + continue; + } pnlPrefs.add(new FLabel.Builder().text(s.getDescription()) .fontAlign(SwingConstants.RIGHT).build(), "w 50%!, h 22px!, gap 0 2% 0 20px"); KeyboardShortcutField field = new KeyboardShortcutField(s); @@ -981,6 +991,10 @@ public final JCheckBox getCbManaLostPrompt() { return cbManaLostPrompt; } + public final JCheckBox getCbYieldExperimentalOptions() { + return cbYieldExperimentalOptions; + } + public final JCheckBox getCbDetailedPaymentDesc() { return cbDetailedPaymentDesc; } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 28c10960578..2e26673c308 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1522,6 +1522,8 @@ lblCloseGameSpectator=This will close this game and you will not be able to resu lblCloseGame=Close Game? lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. +cbYieldExperimentalOptions=Experimental: Enable expanded yield options +nlYieldExperimentalOptions=Adds extended yield options: yield until stack clears, yield until your next turn, smart yield suggestions, and interrupt configuration. Access via right-click on End Turn button. Options in Game toolbar. Requires restart. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this yield to take an action. lblYieldUntilStackClears=Yield Until Stack Clears From 5f4d7c826366ed3fc03588aa77d2ce4b8ff15faf Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Wed, 28 Jan 2026 21:01:24 +1030 Subject: [PATCH 03/33] Fix experimental yield system bugs and improve UX - Fix yield not auto-passing: mayAutoPass() now delegates to shouldAutoYieldForPlayer() for experimental yield modes - Fix keybind/menu not passing priority: added selectButtonOk() call after setting yield mode - Fix re-prompting when already yielding: added getYieldMode() method and isAlreadyYielding() check - Integrate suggestions into prompt UI instead of modal dialogs: suggestions now appear in prompt area with Accept/Decline buttons - Fix "no actions" prompt not firing: hasAvailableActions() now checks actual playability via getAllPossibleAbilities() instead of just checking if hand is non-empty Co-Authored-By: Claude Opus 4.5 --- .../java/forge/control/KeyboardShortcuts.java | 4 + .../forge/screens/match/views/VPrompt.java | 4 + .../gamemodes/match/AbstractGuiGame.java | 17 ++- .../match/input/InputPassPriority.java | 116 ++++++++++-------- .../java/forge/gui/interfaces/IGuiGame.java | 2 + 5 files changed, 92 insertions(+), 51 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 25ff1306893..e4175a0f425 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -121,6 +121,8 @@ public void actionPerformed(final ActionEvent e) { if (matchUI == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } matchUI.getGameController().yieldUntilStackClears(); + // Also pass priority to actually start yielding + matchUI.getGameController().selectButtonOk(); } }; @@ -133,6 +135,8 @@ public void actionPerformed(final ActionEvent e) { if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } if (matchUI.getPlayerCount() >= 3) { matchUI.getGameController().yieldUntilYourNextTurn(); + // Also pass priority to actually start yielding + matchUI.getGameController().selectButtonOk(); } } }; 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 aa0bd334e0b..e1d98ec8807 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 @@ -234,6 +234,8 @@ private void showYieldOptionsMenu(MouseEvent e) { stackItem.addActionListener(evt -> { if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { controller.getMatchUI().getGameController().yieldUntilStackClears(); + // Also pass priority to actually start yielding + controller.getMatchUI().getGameController().selectButtonOk(); } }); menu.add(stackItem); @@ -253,6 +255,8 @@ private void showYieldOptionsMenu(MouseEvent e) { yourNextTurnItem.addActionListener(evt -> { if (controller.getMatchUI().getGameController() != null) { controller.getMatchUI().getGameController().yieldUntilYourNextTurn(); + // Also pass priority to actually start yielding + controller.getMatchUI().getGameController().selectButtonOk(); } }); menu.add(yourNextTurnItem); 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 307bddf0744..f19297b9f17 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -445,7 +445,12 @@ public final void autoPassCancel(final PlayerView player) { @Override public final boolean mayAutoPass(final PlayerView player) { - return autoPassUntilEndOfTurn.contains(player); + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + return true; + } + // Check experimental yield system + return shouldAutoYieldForPlayer(player); } private Timer awaitNextInputTimer; @@ -564,6 +569,16 @@ public final void clearYieldMode(final PlayerView player) { awaitNextInput(); } + @Override + public final YieldMode getYieldMode(final PlayerView player) { + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + return YieldMode.UNTIL_END_OF_TURN; + } + YieldMode mode = playerYieldMode.get(player); + return mode != null ? mode : YieldMode.NONE; + } + @Override public final boolean shouldAutoYieldForPlayer(final PlayerView player) { // Check legacy system first 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 01ea794b86d..f59bc5bfb10 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 @@ -50,6 +50,10 @@ public class InputPassPriority extends InputSyncronizedBase { private List chosenSa; + // Pending yield suggestion state for prompt integration + private YieldMode pendingSuggestion = null; + private String pendingSuggestionMessage = null; + public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } @@ -58,35 +62,52 @@ public InputPassPriority(final PlayerControllerHuman controller) { @Override public final void showMessage() { // Check if experimental yield features are enabled and show smart suggestions - if (isExperimentalYieldEnabled()) { + // Only show suggestions if not already yielding + if (isExperimentalYieldEnabled() && !isAlreadyYielding()) { ForgePreferences prefs = FModel.getPreferences(); + Localizer loc = Localizer.getInstance(); // Suggestion 1: Stack items but can't respond if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_STACK_YIELD) && shouldShowStackYieldPrompt()) { - if (showStackYieldPrompt()) { - getController().getGui().setYieldMode(getOwner(), YieldMode.UNTIL_STACK_CLEARS); - stop(); - return; - } + pendingSuggestion = YieldMode.UNTIL_STACK_CLEARS; + pendingSuggestionMessage = loc.getMessage("lblCannotRespondToStackYieldPrompt"); + showYieldSuggestionPrompt(); + return; } // Suggestion 2: Has cards but no mana else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_MANA) && shouldShowNoManaPrompt()) { - if (showNoManaPrompt()) { - getController().getGui().setYieldMode(getOwner(), getDefaultYieldMode()); - stop(); - return; - } + pendingSuggestion = getDefaultYieldMode(); + pendingSuggestionMessage = loc.getMessage("lblNoManaAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; } // Suggestion 3: No available actions (empty hand, no abilities) else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) && shouldShowNoActionsPrompt()) { - if (showNoActionsPrompt()) { - getController().getGui().setYieldMode(getOwner(), getDefaultYieldMode()); - stop(); - return; - } + pendingSuggestion = getDefaultYieldMode(); + pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; } } + showNormalPrompt(); + } + + private void showYieldSuggestionPrompt() { + Localizer loc = Localizer.getInstance(); + showMessage(pendingSuggestionMessage); + 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(); @@ -100,9 +121,24 @@ else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) && shouldShowNoAct getController().getGui().alertUser(); } + private boolean isAlreadyYielding() { + YieldMode currentMode = getController().getGui().getYieldMode(getOwner()); + return currentMode != null && currentMode != YieldMode.NONE; + } + /** {@inheritDoc} */ @Override protected final void onOk() { + // If accepting a yield suggestion + if (pendingSuggestion != null) { + YieldMode mode = pendingSuggestion; + pendingSuggestion = null; + pendingSuggestionMessage = null; + getController().getGui().setYieldMode(getOwner(), mode); + stop(); + return; + } + passPriority(() -> { getController().macros().addRememberedAction(new PassPriorityAction()); stop(); @@ -112,6 +148,12 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { + // If declining a yield suggestion, show normal prompt + if (pendingSuggestion != null) { + showNormalPrompt(); + return; + } + if (!getController().tryUndoLastAction()) { //undo if possible //otherwise end turn passPriority(() -> { @@ -253,16 +295,6 @@ private boolean canRespondToStack(Game game, Player player) { return false; } - private boolean showStackYieldPrompt() { - Localizer loc = Localizer.getInstance(); - return getController().getGui().showConfirmDialog( - loc.getMessage("lblCannotRespondToStackYieldPrompt"), - loc.getMessage("lblYieldSuggestion"), - loc.getMessage("lblAccept"), - loc.getMessage("lblDecline") - ); - } - private boolean shouldShowNoManaPrompt() { Game game = getController().getGame(); Player player = getController().getPlayer(); @@ -300,16 +332,6 @@ private boolean hasManaAvailable(Player player) { return false; } - private boolean showNoManaPrompt() { - Localizer loc = Localizer.getInstance(); - return getController().getGui().showConfirmDialog( - loc.getMessage("lblNoManaAvailableYieldPrompt"), - loc.getMessage("lblYieldSuggestion"), - loc.getMessage("lblAccept"), - loc.getMessage("lblDecline") - ); - } - private boolean shouldShowNoActionsPrompt() { Player player = getController().getPlayer(); Game game = getController().getGame(); @@ -326,27 +348,21 @@ private boolean shouldShowNoActionsPrompt() { } private boolean hasAvailableActions(Game game, Player player) { - if (!player.getCardsIn(ZoneType.Hand).isEmpty()) { - return true; + // Check hand for actually playable spells (filters by timing, mana, etc.) + for (Card card : player.getCardsIn(ZoneType.Hand)) { + if (!card.getAllPossibleAbilities(player, true).isEmpty()) { + return true; + } } + // Check battlefield for activatable abilities (excluding mana abilities) for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility sa : card.getAllSpellAbilities()) { - if (sa.canPlay() && !sa.isTrigger() && !sa.isManaAbility()) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (!sa.isManaAbility()) { return true; } } } return false; } - - private boolean showNoActionsPrompt() { - Localizer loc = Localizer.getInstance(); - return getController().getGui().showConfirmDialog( - loc.getMessage("lblNoActionsAvailableYieldPrompt"), - loc.getMessage("lblYieldSuggestion"), - loc.getMessage("lblAccept"), - loc.getMessage("lblDecline") - ); - } } diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 8a704e55095..61bda0b6f4d 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -269,6 +269,8 @@ public interface IGuiGame { boolean shouldAutoYieldForPlayer(PlayerView player); + YieldMode getYieldMode(PlayerView player); + int getPlayerCount(); boolean shouldAutoYield(String key); From 284c10a69aba58162ecc3fe9c413d2f6709e9e43 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Wed, 28 Jan 2026 21:53:09 +1030 Subject: [PATCH 04/33] Fix multiplayer yield issues and simplify yield logic - Fix interrupt conditions to only trigger when player is specifically attacked (using getAttackersOf instead of getDefenders.contains) - Separate UNTIL_END_OF_TURN and UNTIL_YOUR_NEXT_TURN end conditions: - UNTIL_END_OF_TURN now clears on UNTAP phase of any new turn - UNTIL_YOUR_NEXT_TURN clears when player's turn starts - Remove yieldTurnOwner/yieldTurnNumber tracking (simplified approach) - Fix menu checkboxes to stay open when toggled Co-Authored-By: Claude Opus 4.5 --- .../forge/screens/match/menus/GameMenu.java | 13 ++++++++- .../gamemodes/match/AbstractGuiGame.java | 28 +++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) 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 22f02ff2cdc..b0b86f138df 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 @@ -233,7 +233,18 @@ private JMenu getYieldOptionsMenu() { } private JCheckBoxMenuItem createYieldCheckbox(String label, FPref pref) { - final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label); + // Custom checkbox that doesn't close the menu when clicked + final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label) { + @Override + protected void processMouseEvent(java.awt.event.MouseEvent e) { + if (e.getID() == java.awt.event.MouseEvent.MOUSE_RELEASED && contains(e.getPoint())) { + doClick(0); + setArmed(true); + } else { + super.processMouseEvent(e); + } + } + }; item.setSelected(prefs.getPrefBoolean(pref)); item.addActionListener(e -> { prefs.setPref(pref, item.isSelected()); 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 f19297b9f17..209a25e978d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -418,7 +418,6 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final // Extended yield mode tracking (experimental feature) private final Map playerYieldMode = Maps.newHashMap(); - private final Map yieldTurnOwner = Maps.newHashMap(); /** * Automatically pass priority until reaching the Cleanup phase of the @@ -552,16 +551,12 @@ public final void setYieldMode(final PlayerView player, final YieldMode mode) { } playerYieldMode.put(player, mode); - if (getGameView() != null && getGameView().getGame() != null) { - yieldTurnOwner.put(player, getGameView().getGame().getPhaseHandler().getPlayerTurn()); - } updateAutoPassPrompt(); } @Override public final void clearYieldMode(final PlayerView player) { playerYieldMode.remove(player); - yieldTurnOwner.remove(player); autoPassUntilEndOfTurn.remove(player); // Legacy compatibility showPromptMessage(player, ""); @@ -610,10 +605,23 @@ public final boolean shouldAutoYieldForPlayer(final PlayerView player) { return switch (mode) { case UNTIL_STACK_CLEARS -> !game.getStack().isEmpty(); - case UNTIL_END_OF_TURN -> yieldTurnOwner.get(player) != null && yieldTurnOwner.get(player).equals(ph.getPlayerTurn()); + case UNTIL_END_OF_TURN -> { + // Yield until the current turn ends - clear when any new turn starts (UNTAP phase) + if (ph.getPhase() == forge.game.phase.PhaseType.UNTAP) { + clearYieldMode(player); + yield false; + } + yield true; + } case UNTIL_YOUR_NEXT_TURN -> { + // Yield until our turn starts forge.game.player.Player playerObj = game.getPlayer(player); - yield !ph.getPlayerTurn().equals(playerObj); + boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + if (isOurTurn) { + clearYieldMode(player); + yield false; + } + yield true; } default -> false; }; @@ -630,15 +638,17 @@ private boolean shouldInterruptYield(final PlayerView player) { forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { + // Only interrupt if there are creatures attacking THIS player specifically if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && - game.getCombat() != null && game.getCombat().getDefenders().contains(p)) { + game.getCombat() != null && !game.getCombat().getAttackersOf(p).isEmpty()) { return true; } } if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { + // Only interrupt if there are creatures attacking THIS player specifically if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && - game.getCombat() != null && game.getCombat().getDefenders().contains(p)) { + game.getCombat() != null && !game.getCombat().getAttackersOf(p).isEmpty()) { return true; } } From f5f7287ff5325760ff5d60567f25dffba4974121 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Wed, 28 Jan 2026 21:59:00 +1030 Subject: [PATCH 05/33] Add PR documentation for experimental yield system Documents the yield system rework including: - Feature overview and yield modes - Smart yield suggestions - Interrupt conditions (with multiplayer scoping) - Technical implementation details - Testing guide and changelog Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 259 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 DOCUMENTATION.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 00000000000..5782ce3ab6b --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,259 @@ +# Yield System Rework - PR Documentation + +## Summary + +This PR adds an experimental, feature-gated yield system to reduce micromanagement in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. + +## Problem Statement + +In multiplayer Magic games (3+ players), the current priority system requires excessive clicking: +- Dozens of priority passes every turn in a 4-player game +- Players must manually pass priority even when they have no possible actions +- This can create click fatigue and slow down gameplay significantly + +## Solution + +Extended yield options that allow players to automatically pass priority until specific conditions are met, with configurable interrupts for important game events. + +## Feature Overview + +### Yield Modes + +| Mode | Description | End Condition | Availability | +|------|-------------|---------------|--------------| +| Until Stack Clears | Auto-pass while stack has items | Stack becomes empty | Always | +| Until End of Turn | Auto-pass until end of current turn | UNTAP phase of any new turn | Always | +| Until Your Next Turn | Auto-pass until you become active player | Your turn starts | 3+ player games only | + +### Access Methods + +1. **Right-Click Menu**: Right-click the "End Turn" button to see yield options +2. **Keyboard Shortcuts** (configurable): + - `Ctrl+Shift+S` - Yield until stack clears + - `Ctrl+Shift+N` - Yield until your next turn + +### Smart Yield Suggestions + +When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** (not modal dialogs) with Accept/Decline buttons: + +1. **Cannot respond to stack**: Player has no instant-speed responses available (checks `getAllPossibleAbilities()`) +2. **No mana available**: Player has cards but no mana sources untapped (not on player's turn) +3. **No actions available**: No playable cards in hand and no activatable non-mana abilities (not on player's turn) + +Each suggestion can be individually enabled/disabled. + +**Note:** Suggestions will not appear if the player is already yielding. + +### Interrupt Conditions + +Existing interrupt conditions while on auto-yield is now configurable in game options menu. +Yield modes can be configured to automatically cancel when: +- Attackers are declared against **you specifically** (default: ON) - uses `getAttackersOf(player)` to only trigger when creatures attack you, not when any player is attacked +- **You** can declare blockers (default: ON) - only triggers when creatures are attacking you +- **You or your permanents** are targeted by a spell/ability (default: ON) +- An opponent casts any spell (default: OFF) +- Combat begins (default: OFF) + +**Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. + +## How to Enable + +1. Open Forge Preferences +2. Find `YIELD_EXPERIMENTAL_OPTIONS` +3. Set to `true` +4. Restart the game + +Once enabled: +- Right-click menu appears on End Turn button +- Keyboard shortcuts become active +- Yield Options submenu appears in Game menu +- Smart suggestions begin appearing (if enabled) + +## Technical Implementation + +### Architecture + +All changes are in the **GUI layer only** - no modifications to core game logic or rules engine: + +``` +forge-gui/ (shared GUI code) +├── YieldMode.java # New enum for yield modes +├── AbstractGuiGame.java # Yield state tracking & logic +├── InputPassPriority.java # Smart suggestion prompts +├── IGuiGame.java # Interface updates +├── IGameController.java # Controller interface +├── PlayerControllerHuman.java # Controller implementation +├── ForgePreferences.java # New preferences +├── NetGameController.java # Network protocol +├── ProtocolMethod.java # Protocol enum +└── en-US.properties # Localization + +forge-gui-desktop/ (desktop-specific) +├── VPrompt.java # Right-click menu +├── GameMenu.java # Yield Options submenu +└── KeyboardShortcuts.java # New shortcuts +``` + +### Key Design Decisions + +1. **Feature-gated**: Master toggle prevents accidental activation; default OFF +2. **GUI layer only**: No changes to `forge-game` rules engine +3. **Backward compatible**: Existing Ctrl+E behavior unchanged +4. **Network-aware**: Protocol methods added for multiplayer sync +5. **Individual toggles**: Each suggestion/interrupt can be configured separately + +### State Management + +```java +// In AbstractGuiGame.java +private final Map playerYieldMode = Maps.newHashMap(); +``` + +The `shouldAutoYieldForPlayer()` method checks: +1. Legacy auto-pass set (backward compatibility) +2. Current yield mode +3. Interrupt conditions +4. Mode-specific end conditions: + - `UNTIL_STACK_CLEARS`: Continues while stack is non-empty + - `UNTIL_END_OF_TURN`: Clears when UNTAP phase detected (new turn started) + - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes the active player + +## Files Changed + +### New Files (1) +- `forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java` + +### Modified Files (12) + +**forge-gui (8 files):** +- `AbstractGuiGame.java` - Yield mode tracking, interrupt logic +- `InputPassPriority.java` - Smart suggestion prompts +- `IGuiGame.java` - Interface methods +- `IGameController.java` - Controller interface +- `PlayerControllerHuman.java` - Controller implementation +- `ForgePreferences.java` - 11 new preferences +- `NetGameController.java` - Network protocol implementation +- `ProtocolMethod.java` - Protocol enum values +- `en-US.properties` - 25+ localization strings + +**forge-gui-desktop (3 files):** +- `VPrompt.java` - Right-click menu on End Turn button +- `GameMenu.java` - Yield Options submenu +- `KeyboardShortcuts.java` - New keyboard shortcuts + +## New Preferences + +```java +// Master toggle +YIELD_EXPERIMENTAL_OPTIONS("false") + +// Smart suggestions +YIELD_SUGGEST_STACK_YIELD("true") +YIELD_SUGGEST_NO_MANA("true") +YIELD_SUGGEST_NO_ACTIONS("true") + +// Interrupt conditions +YIELD_INTERRUPT_ON_ATTACKERS("true") +YIELD_INTERRUPT_ON_BLOCKERS("true") +YIELD_INTERRUPT_ON_TARGETING("true") +YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") +YIELD_INTERRUPT_ON_COMBAT("false") + +// Keyboard shortcuts +SHORTCUT_YIELD_UNTIL_STACK_CLEARS("17 16 83") // Ctrl+Shift+S +SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N +``` + +## Testing Guide + +### Prerequisites +1. Enable `YIELD_EXPERIMENTAL_OPTIONS` in preferences +2. Start a 3+ player game (for full feature testing) + +### Test Cases + +#### Master Toggle +- [ ] Feature OFF by default +- [ ] Right-click menu hidden when OFF +- [ ] Keyboard shortcuts inactive when OFF +- [ ] Existing Ctrl+E behavior unchanged when OFF + +#### Yield Modes +- [ ] Until Stack Clears - stops when stack empties +- [ ] Until End of Turn - stops at UNTAP phase of next turn (not cleanup) +- [ ] Until Your Next Turn - stops when YOU become active player +- [ ] Until Your Next Turn - only available in 3+ player games +- [ ] Yield modes do NOT persist after your turn completes + +#### Access Methods +- [ ] Right-click End Turn button shows popup menu +- [ ] Keyboard shortcuts trigger correct yield modes +- [ ] Menu options reflect player count (hide 3+ player options in 2-player) + +#### Smart Suggestions +- [ ] Stack suggestion appears when player can't respond (in prompt area, not dialog) +- [ ] No-mana suggestion appears when cards in hand but no mana +- [ ] No-actions suggestion appears when no possible plays (checks actual playability) +- [ ] Suggestions don't appear on your own turn +- [ ] Suggestions don't appear if already yielding +- [ ] Each suggestion respects its individual toggle +- [ ] Accept button activates yield mode +- [ ] Decline button shows normal priority prompt + +#### Interrupts +- [ ] Attackers declared against you cancels yield +- [ ] Attackers declared against OTHER players does NOT cancel your yield (multiplayer) +- [ ] Blockers phase cancels yield only when creatures are attacking YOU +- [ ] Being targeted (you or your permanents) cancels yield +- [ ] Spells targeting other players does NOT cancel your yield +- [ ] Each interrupt respects its toggle setting + +#### Visual Feedback +- [ ] Prompt area shows "Yielding until..." message +- [ ] Cancel button allows breaking out of yield +- [ ] Yield Options submenu checkboxes stay open when toggled (menu doesn't close) + +#### Network Play +- [ ] Yield modes sync correctly between clients +- [ ] No desync when one player uses extended yields + +## Risk Assessment + +### Low Risk +- Feature-gated with default OFF +- No changes to game rules or logic +- Existing behavior unchanged when feature disabled + +### Considerations +- **Mobile**: Changes are desktop-only (VPrompt, GameMenu, KeyboardShortcuts) +- **Network**: Protocol changes require matching client versions +- **Preferences**: New preferences added; old preference files compatible + +## Changelog + +### 2026-01-28 - Multiplayer Fixes & Simplified Yield Logic + +**Breaking Changes:** +- `UNTIL_END_OF_TURN` now ends at UNTAP phase of any new turn (previously was tied to turn owner tracking) + +**Bug Fixes:** +1. **Multiplayer interrupt scoping** - Attack/blocker interrupts now only trigger when the player specifically is being attacked, not when any player is attacked. Changed from `getDefenders().contains(p)` to `!getAttackersOf(p).isEmpty()`. + +2. **Yield continuation bug** - Fixed issue where yields would continue past the player's turn. Simplified logic to clear all yields when player's turn starts. + +3. **Separated yield mode end conditions**: + - `UNTIL_END_OF_TURN`: Clears on UNTAP phase (any new turn) + - `UNTIL_YOUR_NEXT_TURN`: Clears when player's specific turn starts + +4. **Smart suggestions re-prompting** - Added `isAlreadyYielding()` check to prevent re-prompting when already yielding. + +5. **Prompt integration** - Changed smart suggestions from modal dialogs to prompt area with Accept/Decline buttons. + +6. **Menu checkbox behavior** - Yield Options submenu checkboxes now stay open when clicked (custom `processMouseEvent` override). + +7. **No actions check** - Fixed `hasAvailableActions()` to check actual playability via `getAllPossibleAbilities()` instead of just checking hand size. + +8. **Keybind/menu priority pass** - Added `selectButtonOk()` call after setting yield mode to immediately pass priority. + +**Removed:** +- `yieldTurnNumber` map (turn tracking simplified) \ No newline at end of file From 17b8822ef79aaeaf130cb038080386df48104db0 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Thu, 29 Jan 2026 06:49:58 +1030 Subject: [PATCH 06/33] Improve yield system integration and fix End Turn behavior - End Turn button now uses experimental yield system when enabled - Exclude triggered abilities from "opponent spell" interrupt (targeted triggers handled by "targeting" interrupt instead) - Move Auto-Yields into Yield Options submenu when experimental enabled - Track turn number for UNTIL_END_OF_TURN to respect phase stops - Fix yield re-enable after interrupt (track turn on first check) Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 57 +++++++++++++++---- .../forge/screens/match/menus/GameMenu.java | 7 ++- .../gamemodes/match/AbstractGuiGame.java | 20 ++++++- .../match/input/InputPassPriority.java | 8 ++- 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5782ce3ab6b..11af781ccd1 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2,18 +2,18 @@ ## Summary -This PR adds an experimental, feature-gated yield system to reduce micromanagement in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. +This PR adds an expanded yield system to reduce micromanagement in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. ## Problem Statement In multiplayer Magic games (3+ players), the current priority system requires excessive clicking: -- Dozens of priority passes every turn in a 4-player game +- Dozens of priority passes every turn in multiplayer game - Players must manually pass priority even when they have no possible actions - This can create click fatigue and slow down gameplay significantly ## Solution -Extended yield options that allow players to automatically pass priority until specific conditions are met, with configurable interrupts for important game events. +Extended yield options that allow players to automatically pass priority until specific conditions are met, set yield interrupts for important game events, and smart suggestions prompting players to enable auto-yield in situations where they cannot take actions. All configurable through in-game menu options. ## Feature Overview @@ -34,7 +34,7 @@ Extended yield options that allow players to automatically pass priority until s ### Smart Yield Suggestions -When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** (not modal dialogs) with Accept/Decline buttons: +When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** with Accept/Decline buttons: 1. **Cannot respond to stack**: Player has no instant-speed responses available (checks `getAllPossibleAbilities()`) 2. **No mana available**: Player has cards but no mana sources untapped (not on player's turn) @@ -46,7 +46,7 @@ Each suggestion can be individually enabled/disabled. ### Interrupt Conditions -Existing interrupt conditions while on auto-yield is now configurable in game options menu. +Existing interrupt conditions while on auto-yield are now configurable through in-game options menu. Yield modes can be configured to automatically cancel when: - Attackers are declared against **you specifically** (default: ON) - uses `getAttackersOf(player)` to only trigger when creatures attack you, not when any player is attacked - **You** can declare blockers (default: ON) - only triggers when creatures are attacking you @@ -59,14 +59,14 @@ Yield modes can be configured to automatically cancel when: ## How to Enable 1. Open Forge Preferences -2. Find `YIELD_EXPERIMENTAL_OPTIONS` +2. Find `Experimental Yield Options` 3. Set to `true` 4. Restart the game Once enabled: - Right-click menu appears on End Turn button - Keyboard shortcuts become active -- Yield Options submenu appears in Game menu +- Yield Options submenu appears in: Forge > Game > Yield Options. - Smart suggestions begin appearing (if enabled) ## Technical Implementation @@ -102,11 +102,30 @@ forge-gui-desktop/ (desktop-specific) 4. **Network-aware**: Protocol methods added for multiplayer sync 5. **Individual toggles**: Each suggestion/interrupt can be configured separately +### End Turn Button Behavior + +The "End Turn" button (Cancel button during priority) has different behavior depending on whether experimental yields are enabled: + +**Legacy Mode (experimental yields OFF):** +- Uses `autoPassUntilEndOfTurn` system +- Cancelled when ANY opponent casts a spell or activates an ability (even if it doesn't affect you) +- Cancelled at cleanup phase for all players +- Good for 1v1 where you always want to respond to opponent actions + +**Experimental Mode (experimental yields ON):** +- Uses `YieldMode.UNTIL_END_OF_TURN` with smart interrupts +- Only interrupted based on your configured interrupt settings: + - When you're attacked (if enabled) + - When you or your permanents are targeted (if enabled) + - When opponents cast spells (if enabled) - excludes triggered abilities +- Better for multiplayer where you don't need to respond to actions between other players + ### State Management ```java // In AbstractGuiGame.java private final Map playerYieldMode = Maps.newHashMap(); +private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set ``` The `shouldAutoYieldForPlayer()` method checks: @@ -115,7 +134,7 @@ The `shouldAutoYieldForPlayer()` method checks: 3. Interrupt conditions 4. Mode-specific end conditions: - `UNTIL_STACK_CLEARS`: Continues while stack is non-empty - - `UNTIL_END_OF_TURN`: Clears when UNTAP phase detected (new turn started) + - `UNTIL_END_OF_TURN`: Clears when turn number changes (tracked via `yieldStartTurn`) - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes the active player ## Files Changed @@ -189,6 +208,8 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N - [ ] Right-click End Turn button shows popup menu - [ ] Keyboard shortcuts trigger correct yield modes - [ ] Menu options reflect player count (hide 3+ player options in 2-player) +- [ ] "End Turn" button (Cancel) uses experimental yield when feature enabled +- [ ] "End Turn" button uses legacy behavior when feature disabled #### Smart Suggestions - [ ] Stack suggestion appears when player can't respond (in prompt area, not dialog) @@ -206,6 +227,8 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N - [ ] Blockers phase cancels yield only when creatures are attacking YOU - [ ] Being targeted (you or your permanents) cancels yield - [ ] Spells targeting other players does NOT cancel your yield +- [ ] "Opponent spell" only triggers for spells and activated abilities, NOT triggered abilities + - Triggered abilities that target you are handled by the "targeting" interrupt instead - [ ] Each interrupt respects its toggle setting #### Visual Feedback @@ -231,10 +254,20 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N ## Changelog -### 2026-01-28 - Multiplayer Fixes & Simplified Yield Logic +### 2026-01-29 - End Turn Button Integration & Trigger Exclusion + +**Improvements:** +1. **End Turn button uses experimental yields** - When experimental yield options are enabled, the "End Turn" button now uses `YieldMode.UNTIL_END_OF_TURN` with smart interrupts instead of the legacy behavior that cancels on any opponent spell. -**Breaking Changes:** -- `UNTIL_END_OF_TURN` now ends at UNTAP phase of any new turn (previously was tied to turn owner tracking) +2. **Opponent spell excludes triggers** - The "interrupt on opponent spell" setting now only triggers for spells and activated abilities, NOT triggered abilities. Triggered abilities that target you are handled by the "targeting" interrupt instead. This prevents unwanted interrupts from attack triggers when other players are attacked. + +3. **Menu consolidation** - When experimental yields are enabled, "Auto-Yields" menu item is moved inside the "Yield Options" submenu instead of being a separate item. When disabled, Auto-Yields appears in the main Game menu as before. + +4. **End of turn yield fix** - `UNTIL_END_OF_TURN` now tracks the turn number when the yield was set and clears when the turn number changes. This ensures phase stops on the next turn work correctly, since UNTAP/CLEANUP phases don't give priority. + +5. **Yield re-enable fix** - Fixed issue where accepting a yield suggestion after an interrupt would immediately clear the yield. If turn number wasn't tracked when yield was set, it's now tracked on first check. + +### 2026-01-28 - Multiplayer Fixes & Simplified Yield Logic **Bug Fixes:** 1. **Multiplayer interrupt scoping** - Attack/blocker interrupts now only trigger when the player specifically is being attacked, not when any player is attacked. Changed from `getDefenders().contains(p)` to `!getAttackersOf(p).isEmpty()`. @@ -242,7 +275,7 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N 2. **Yield continuation bug** - Fixed issue where yields would continue past the player's turn. Simplified logic to clear all yields when player's turn starts. 3. **Separated yield mode end conditions**: - - `UNTIL_END_OF_TURN`: Clears on UNTAP phase (any new turn) + - `UNTIL_END_OF_TURN`: Clears when turn number changes (superseded by 2026-01-29 fix) - `UNTIL_YOUR_NEXT_TURN`: Clears when player's specific turn starts 4. **Smart suggestions re-prompting** - Added `isAlreadyYielding()` check to prevent re-prompting when already yielding. 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 b0b86f138df..a0ff36f7ffe 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 @@ -50,9 +50,10 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); - menu.add(getMenuItem_AutoYields()); if (prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { menu.add(getYieldOptionsMenu()); + } else { + menu.add(getMenuItem_AutoYields()); } menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); @@ -213,6 +214,10 @@ private JMenu getYieldOptionsMenu() { final Localizer localizer = Localizer.getInstance(); final JMenu yieldMenu = new JMenu(localizer.getMessage("lblYieldOptions")); + // Auto-Yields (manage per-ability yields) + yieldMenu.add(getMenuItem_AutoYields()); + yieldMenu.addSeparator(); + // Sub-menu 1: Interrupt Settings final JMenu interruptMenu = new JMenu(localizer.getMessage("lblInterruptSettings")); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS)); 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 209a25e978d..28924468d44 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -418,6 +418,7 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final // Extended yield mode tracking (experimental feature) private final Map playerYieldMode = Maps.newHashMap(); + private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set /** * Automatically pass priority until reaching the Cleanup phase of the @@ -551,12 +552,17 @@ public final void setYieldMode(final PlayerView player, final YieldMode mode) { } playerYieldMode.put(player, mode); + // Track turn number for UNTIL_END_OF_TURN mode + if (mode == YieldMode.UNTIL_END_OF_TURN && getGameView() != null && getGameView().getGame() != null) { + yieldStartTurn.put(player, getGameView().getGame().getPhaseHandler().getTurn()); + } updateAutoPassPrompt(); } @Override public final void clearYieldMode(final PlayerView player) { playerYieldMode.remove(player); + yieldStartTurn.remove(player); autoPassUntilEndOfTurn.remove(player); // Legacy compatibility showPromptMessage(player, ""); @@ -606,8 +612,15 @@ public final boolean shouldAutoYieldForPlayer(final PlayerView player) { return switch (mode) { case UNTIL_STACK_CLEARS -> !game.getStack().isEmpty(); case UNTIL_END_OF_TURN -> { - // Yield until the current turn ends - clear when any new turn starts (UNTAP phase) - if (ph.getPhase() == forge.game.phase.PhaseType.UNTAP) { + // Yield until end of the turn when yield was set - clear when turn number changes + Integer startTurn = yieldStartTurn.get(player); + int currentTurn = ph.getTurn(); + if (startTurn == null) { + // Turn wasn't tracked when yield was set - track it now + yieldStartTurn.put(player, currentTurn); + yield true; + } + if (currentTurn > startTurn) { clearYieldMode(player); yield false; } @@ -664,7 +677,8 @@ private boolean shouldInterruptYield(final PlayerView player) { if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { if (!game.getStack().isEmpty()) { forge.game.spellability.SpellAbility topSa = game.getStack().peekAbility(); - if (topSa != null && !topSa.getActivatingPlayer().equals(p)) { + // Exclude triggered abilities - if they target you, the "targeting" setting handles that + if (topSa != null && !topSa.isTrigger() && !topSa.getActivatingPlayer().equals(p)) { return true; } } 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 f59bc5bfb10..5701eb8dfa0 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 @@ -157,7 +157,13 @@ protected final void onCancel() { if (!getController().tryUndoLastAction()) { //undo if possible //otherwise end turn passPriority(() -> { - getController().autoPassUntilEndOfTurn(); + if (isExperimentalYieldEnabled()) { + // Use experimental yield system with smart interrupts + getController().getGui().setYieldMode(getOwner(), YieldMode.UNTIL_END_OF_TURN); + } else { + // Legacy behavior - cancels on any opponent spell + getController().autoPassUntilEndOfTurn(); + } stop(); }); } From 35a7c3ffec861087b9698b9d203bdb8bbe94e94b Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Thu, 29 Jan 2026 07:37:16 +1030 Subject: [PATCH 07/33] Add authorship section to PR documentation Clarifies that all code was written by Claude AI under human instruction. Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 11af781ccd1..ab8ccf0c13c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -289,4 +289,8 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N 8. **Keybind/menu priority pass** - Added `selectButtonOk()` call after setting yield mode to immediately pass priority. **Removed:** -- `yieldTurnNumber` map (turn tracking simplified) \ No newline at end of file +- `yieldTurnNumber` map (turn tracking simplified) + +## Authorship + +All code in this PR was written by Claude AI (Anthropic) under human instruction and direction. The human collaborator provided requirements, design decisions, testing feedback, and iterative guidance throughout development. Claude AI implemented all code changes, documentation, and technical solutions. \ No newline at end of file From 34fc365c38d8d4eabe6ede6a5ce96fd860763ea9 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Thu, 29 Jan 2026 18:41:21 +1030 Subject: [PATCH 08/33] Add new yield modes and F-key hotkeys for yield system New features: - UNTIL_BEFORE_COMBAT: Yield until entering combat phase (F3) - UNTIL_END_STEP: Yield until end step phase (F4) - Updated hotkeys: F1-F5 for yield modes, ESC to cancel yield Bug fixes: - UNTIL_STACK_CLEARS now checks simultaneous stack entries - UNTIL_END_OF_TURN no longer interrupted by combat on own turn Co-Authored-By: Claude Opus 4.5 --- .documentation/YieldRework-PR.md | 91 +++++++++++++++++++ DOCUMENTATION.md | 49 ++++++++-- .../java/forge/control/KeyboardShortcuts.java | 39 ++++++++ .../forge/screens/match/views/VPrompt.java | 39 +++++++- forge-gui/res/languages/en-US.properties | 7 ++ .../gamemodes/match/AbstractGuiGame.java | 33 ++++++- .../java/forge/gamemodes/match/YieldMode.java | 4 +- .../net/client/NetGameController.java | 15 +++ .../forge/interfaces/IGameController.java | 6 ++ .../properties/ForgePreferences.java | 7 +- .../forge/player/PlayerControllerHuman.java | 12 +++ 11 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 .documentation/YieldRework-PR.md diff --git a/.documentation/YieldRework-PR.md b/.documentation/YieldRework-PR.md new file mode 100644 index 00000000000..2595fd84f1b --- /dev/null +++ b/.documentation/YieldRework-PR.md @@ -0,0 +1,91 @@ +# Pull Request: Experimental Yield System for Multiplayer + +**Branch:** `YieldRework` +**Target:** `master` +**Status:** Draft + +## Title + +Add experimental yield system for reduced multiplayer micromanagement + +## Summary + +This PR adds a feature-gated yield system to reduce excessive clicking in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. + +See [DOCUMENTATION.md](../DOCUMENTATION.md) for complete technical documentation. + +## Problem + +In multiplayer Magic games (3+ players), the current priority system requires excessive clicking: +- Dozens of priority passes every turn in a 4-player game +- Players must manually pass priority even when they have no possible actions +- This creates click fatigue and slows down gameplay significantly + +## Solution + +Extended yield options that automatically pass priority until specific conditions are met, with configurable interrupts for important game events. + +## Key Features + +### Yield Modes +| Mode | End Condition | Hotkey | +|------|---------------|--------| +| Until End of Turn | Turn number changes | F1 | +| Until Stack Clears | Stack empty (including simultaneous triggers) | F2 | +| Until Before Combat | COMBAT_BEGIN phase or later | F3 | +| Until End Step | END_OF_TURN or CLEANUP phase | F4 | +| Until Your Next Turn | Your turn starts (3+ players only) | F5 | + +### Access Methods +- Right-click "End Turn" button for yield options menu +- Keyboard shortcuts: F1-F5 for yield modes, ESC to cancel +- Game menu → Yield Options submenu + +### Smart Suggestions +Prompts appear when player likely cannot act: +- Cannot respond to stack (no instant-speed options) +- No mana available (cards in hand but tapped out) +- No actions available (empty hand, no abilities) + +### Interrupt Conditions (Configurable) +- Attackers declared against **you** (multiplayer-aware) +- Blockers phase when **you** are being attacked +- **You or your permanents** targeted +- Any opponent spell cast +- Combat begins + +## Files Changed + +**New (1):** +- `forge-gui/.../YieldMode.java` + +**Modified (12):** +- `forge-gui`: AbstractGuiGame, InputPassPriority, IGuiGame, IGameController, PlayerControllerHuman, ForgePreferences, NetGameController, ProtocolMethod, en-US.properties +- `forge-gui-desktop`: VPrompt, GameMenu, KeyboardShortcuts + +## How to Enable + +1. Open Forge Preferences +2. Set `YIELD_EXPERIMENTAL_OPTIONS` to `true` +3. Restart the game + +## Testing Checklist + +- [ ] Feature disabled by default +- [ ] Yield modes end at correct conditions +- [ ] Multiplayer: interrupts only trigger for YOUR attacks/targeting +- [ ] Smart suggestions appear in prompt area (not modal dialogs) +- [ ] Menu checkboxes stay open when toggled +- [ ] Network play: no desync with extended yields + +## Risk Assessment + +**Low Risk:** +- Feature-gated with default OFF +- No changes to `forge-game` rules engine +- Existing Ctrl+E behavior unchanged +- GUI layer changes only + +**Considerations:** +- Desktop-only (mobile not affected) +- Network protocol additions require matching client versions diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ab8ccf0c13c..a9cce446a73 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -21,16 +21,22 @@ Extended yield options that allow players to automatically pass priority until s | Mode | Description | End Condition | Availability | |------|-------------|---------------|--------------| -| Until Stack Clears | Auto-pass while stack has items | Stack becomes empty | Always | -| Until End of Turn | Auto-pass until end of current turn | UNTAP phase of any new turn | Always | +| Until End of Turn | Auto-pass until end of current turn | Turn number changes | Always | +| Until Stack Clears | Auto-pass while stack has items | Stack becomes empty (including simultaneous triggers) | Always | +| Until Before Combat | Auto-pass until combat begins | COMBAT_BEGIN phase or later | Always | +| Until End Step | Auto-pass until end step | END_OF_TURN or CLEANUP phase | Always | | Until Your Next Turn | Auto-pass until you become active player | Your turn starts | 3+ player games only | ### Access Methods 1. **Right-Click Menu**: Right-click the "End Turn" button to see yield options -2. **Keyboard Shortcuts** (configurable): - - `Ctrl+Shift+S` - Yield until stack clears - - `Ctrl+Shift+N` - Yield until your next turn +2. **Keyboard Shortcuts** (F-keys to avoid conflict with ability selection 1-9): + - `F1` - Yield until end of turn + - `F2` - Yield until stack clears + - `F3` - Yield until before combat + - `F4` - Yield until end step + - `F5` - Yield until your next turn (3+ players) + - `ESC` - Cancel active yield ### Smart Yield Suggestions @@ -133,9 +139,11 @@ The `shouldAutoYieldForPlayer()` method checks: 2. Current yield mode 3. Interrupt conditions 4. Mode-specific end conditions: - - `UNTIL_STACK_CLEARS`: Continues while stack is non-empty + - `UNTIL_STACK_CLEARS`: Clears when stack is empty AND no simultaneous stack entries - `UNTIL_END_OF_TURN`: Clears when turn number changes (tracked via `yieldStartTurn`) - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes the active player + - `UNTIL_BEFORE_COMBAT`: Clears at COMBAT_BEGIN phase or any phase after + - `UNTIL_END_STEP`: Clears at END_OF_TURN or CLEANUP phase ## Files Changed @@ -178,9 +186,12 @@ YIELD_INTERRUPT_ON_TARGETING("true") YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") YIELD_INTERRUPT_ON_COMBAT("false") -// Keyboard shortcuts -SHORTCUT_YIELD_UNTIL_STACK_CLEARS("17 16 83") // Ctrl+Shift+S -SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N +// Keyboard shortcuts (F-keys) +SHORTCUT_YIELD_UNTIL_END_OF_TURN("112") // F1 +SHORTCUT_YIELD_UNTIL_STACK_CLEARS("113") // F2 +SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("114") // F3 +SHORTCUT_YIELD_UNTIL_END_STEP("115") // F4 +SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 ``` ## Testing Guide @@ -254,6 +265,26 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78") // Ctrl+Shift+N ## Changelog +### 2026-01-29 - New Yield Modes and F-Key Hotkeys + +**New Features:** +1. **UNTIL_BEFORE_COMBAT mode** - Yield until entering the COMBAT_BEGIN phase. Useful for taking actions in main phase before combat. + +2. **UNTIL_END_STEP mode** - Yield until the END_OF_TURN or CLEANUP phase. Useful for end-of-turn effects. + +3. **F-key hotkeys** - Updated hotkey scheme to avoid conflicts with ability selection (1-9): + - F1: Yield until end of turn + - F2: Yield until stack clears + - F3: Yield until before combat + - F4: Yield until end step + - F5: Yield until your next turn + - ESC: Cancel active yield + +**Bug Fixes:** +1. **Stack clears with simultaneous triggers** - UNTIL_STACK_CLEARS now checks `hasSimultaneousStackEntries()` in addition to `isEmpty()` to properly wait for all triggers to resolve. + +2. **End of turn on own turn** - UNTIL_END_OF_TURN no longer gets interrupted by YIELD_INTERRUPT_ON_COMBAT when it's the player's own turn, allowing the yield to continue through combat. + ### 2026-01-29 - End Turn Button Integration & Trigger Exclusion **Improvements:** 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 e4175a0f425..af8e9b99f72 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -141,6 +141,42 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Yield until end of turn (experimental). */ + final Action actYieldUntilEndOfTurn = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.getGameController().yieldUntilEndOfTurn(); + matchUI.getGameController().selectButtonOk(); + } + }; + + /** Yield until before combat (experimental). */ + final Action actYieldUntilBeforeCombat = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.getGameController().yieldUntilBeforeCombat(); + matchUI.getGameController().selectButtonOk(); + } + }; + + /** Yield until end step (experimental). */ + final Action actYieldUntilEndStep = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.getGameController().yieldUntilEndStep(); + matchUI.getGameController().selectButtonOk(); + } + }; + /** Alpha Strike. */ final Action actAllAttack = new AbstractAction() { @Override @@ -236,7 +272,10 @@ public void actionPerformed(ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWDEV, localizer.getMessage("lblSHORTCUT_SHOWDEV"), actShowDev, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CONCEDE, localizer.getMessage("lblSHORTCUT_CONCEDE"), actConcede, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ENDTURN, localizer.getMessage("lblSHORTCUT_ENDTURN"), actEndTurn, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_END_OF_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_END_OF_TURN"), actYieldUntilEndOfTurn, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS"), actYieldUntilStackClears, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_BEFORE_COMBAT"), actYieldUntilBeforeCombat, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_END_STEP, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_END_STEP"), actYieldUntilEndStep, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN"), actYieldUntilYourNextTurn, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ALPHASTRIKE, localizer.getMessage("lblSHORTCUT_ALPHASTRIKE"), actAllAttack, am, im)); list.add(new Shortcut(FPref.SHORTCUT_SHOWTARGETING, localizer.getMessage("lblSHORTCUT_SHOWTARGETING"), actTgtOverlay, 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 e1d98ec8807..228baf10c6d 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 @@ -33,6 +33,8 @@ import javax.swing.SwingUtilities; import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMode; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; @@ -78,6 +80,20 @@ public void setCardView(final CardView card) { @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + // Try to cancel yield first if experimental options enabled + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + if (controller.getMatchUI() != null) { + PlayerView player = controller.getMatchUI().getCurrentPlayer(); + if (player != null) { + YieldMode currentYield = controller.getMatchUI().getYieldMode(player); + if (currentYield != null && currentYield != YieldMode.NONE) { + controller.getMatchUI().clearYieldMode(player); + return; + } + } + } + } + // Existing ESC behavior if (btnCancel.isEnabled()) { if (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals("End Turn")) { btnCancel.doClick(); @@ -244,11 +260,32 @@ private void showYieldOptionsMenu(MouseEvent e) { JMenuItem turnItem = new JMenuItem(loc.getMessage("lblYieldUntilEndOfTurn")); turnItem.addActionListener(evt -> { if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().passPriorityUntilEndOfTurn(); + controller.getMatchUI().getGameController().yieldUntilEndOfTurn(); + controller.getMatchUI().getGameController().selectButtonOk(); } }); menu.add(turnItem); + // Until Combat + JMenuItem combatItem = new JMenuItem(loc.getMessage("lblYieldUntilBeforeCombat")); + combatItem.addActionListener(evt -> { + if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().yieldUntilBeforeCombat(); + controller.getMatchUI().getGameController().selectButtonOk(); + } + }); + menu.add(combatItem); + + // Until End Step + JMenuItem endStepItem = new JMenuItem(loc.getMessage("lblYieldUntilEndStep")); + endStepItem.addActionListener(evt -> { + if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().yieldUntilEndStep(); + controller.getMatchUI().getGameController().selectButtonOk(); + } + }); + menu.add(endStepItem); + // Until Your Next Turn (only in 3+ player games) if (controller.getMatchUI() != null && controller.getMatchUI().getPlayerCount() >= 3) { JMenuItem yourNextTurnItem = new JMenuItem(loc.getMessage("lblYieldUntilYourNextTurn")); diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 2e26673c308..dd33b6fcfac 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1529,6 +1529,10 @@ lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this lblYieldUntilStackClears=Yield Until Stack Clears lblYieldUntilEndOfTurn=Yield Until End of Turn lblYieldUntilYourNextTurn=Yield Until Your Next Turn +lblYieldingUntilBeforeCombat=Yielding until combat.\nYou may cancel this yield to take an action. +lblYieldUntilBeforeCombat=Yield Until Combat +lblYieldingUntilEndStep=Yielding until end step.\nYou may cancel this yield to take an action. +lblYieldUntilEndStep=Yield Until End Step lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? lblNoManaAvailableYieldPrompt=You have no mana available. Yield until your turn? lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? @@ -1546,7 +1550,10 @@ lblInterruptOnCombat=At beginning of combat lblSuggestStackYield=When can't respond to stack lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available +lblSHORTCUT_YIELD_UNTIL_END_OF_TURN=Yield Until End of Turn lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS=Yield Until Stack Clears +lblSHORTCUT_YIELD_UNTIL_BEFORE_COMBAT=Yield Until Combat +lblSHORTCUT_YIELD_UNTIL_END_STEP=Yield Until End Step lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN=Yield Until Your Next Turn lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: 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 28924468d44..d4376d80c92 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -527,6 +527,8 @@ public final void updateAutoPassPrompt() { case UNTIL_STACK_CLEARS -> loc.getMessage("lblYieldingUntilStackClears"); case UNTIL_END_OF_TURN -> loc.getMessage("lblYieldingUntilEndOfTurn"); case UNTIL_YOUR_NEXT_TURN -> loc.getMessage("lblYieldingUntilYourNextTurn"); + case UNTIL_BEFORE_COMBAT -> loc.getMessage("lblYieldingUntilBeforeCombat"); + case UNTIL_END_STEP -> loc.getMessage("lblYieldingUntilEndStep"); default -> ""; }; showPromptMessage(player, message); @@ -610,7 +612,14 @@ public final boolean shouldAutoYieldForPlayer(final PlayerView player) { forge.game.phase.PhaseHandler ph = game.getPhaseHandler(); return switch (mode) { - case UNTIL_STACK_CLEARS -> !game.getStack().isEmpty(); + case UNTIL_STACK_CLEARS -> { + boolean stackEmpty = game.getStack().isEmpty() && !game.getStack().hasSimultaneousStackEntries(); + if (stackEmpty) { + clearYieldMode(player); + yield false; + } + yield true; + } case UNTIL_END_OF_TURN -> { // Yield until end of the turn when yield was set - clear when turn number changes Integer startTurn = yieldStartTurn.get(player); @@ -636,6 +645,22 @@ public final boolean shouldAutoYieldForPlayer(final PlayerView player) { } yield true; } + case UNTIL_BEFORE_COMBAT -> { + forge.game.phase.PhaseType phase = ph.getPhase(); + if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)) { + clearYieldMode(player); + yield false; + } + yield true; + } + case UNTIL_END_STEP -> { + forge.game.phase.PhaseType phase = ph.getPhase(); + if (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP) { + clearYieldMode(player); + yield false; + } + yield true; + } default -> false; }; } @@ -686,7 +711,11 @@ private boolean shouldInterruptYield(final PlayerView player) { if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { - return true; + YieldMode mode = playerYieldMode.get(player); + // Don't interrupt UNTIL_END_OF_TURN on our own turn + if (!(mode == YieldMode.UNTIL_END_OF_TURN && game.getPhaseHandler().getPlayerTurn().equals(p))) { + return true; + } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java index c9581875420..9a59a40cee3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java @@ -25,7 +25,9 @@ public enum YieldMode { NONE("No auto-yield"), UNTIL_STACK_CLEARS("Yield until stack clears"), UNTIL_END_OF_TURN("Yield until end of turn"), - UNTIL_YOUR_NEXT_TURN("Yield until your next turn"); + UNTIL_YOUR_NEXT_TURN("Yield until your next turn"), + UNTIL_BEFORE_COMBAT("Yield until combat"), + UNTIL_END_STEP("Yield until end step"); private final String description; 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 a75b18f75f1..67f48a7a813 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 @@ -83,6 +83,21 @@ public void yieldUntilYourNextTurn() { send(ProtocolMethod.yieldUntilYourNextTurn); } + @Override + public void yieldUntilBeforeCombat() { + // Stub for network play - yield modes handled locally + } + + @Override + public void yieldUntilEndStep() { + // Stub for network play - yield modes handled locally + } + + @Override + public void yieldUntilEndOfTurn() { + // Stub for network play - yield modes handled locally + } + @Override public void passPriority() { send(ProtocolMethod.passPriority); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 9be0962964c..fb687e92b67 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -33,6 +33,12 @@ public interface IGameController { void yieldUntilYourNextTurn(); + void yieldUntilBeforeCombat(); + + void yieldUntilEndStep(); + + void yieldUntilEndOfTurn(); + void selectPlayer(PlayerView playerView, ITriggerEvent triggerEvent); boolean selectCard(CardView cardView, List otherCardViewsToSelect, ITriggerEvent triggerEvent); 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 77ba1f6a256..7b6cad04f03 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -298,8 +298,11 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), - SHORTCUT_YIELD_UNTIL_STACK_CLEARS("17 16 83"), // Ctrl+Shift+S - SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("17 16 78"), // Ctrl+Shift+N + SHORTCUT_YIELD_UNTIL_END_OF_TURN("112"), // F1 key + SHORTCUT_YIELD_UNTIL_STACK_CLEARS("113"), // F2 key + SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("114"), // F3 key + SHORTCUT_YIELD_UNTIL_END_STEP("115"), // F4 key + SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116"), // F5 key LAST_IMPORTED_CUBE_ID(""); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 3fd9512343e..f2d0077bba2 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3263,6 +3263,18 @@ public void yieldUntilYourNextTurn() { getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_YOUR_NEXT_TURN); } + public void yieldUntilBeforeCombat() { + getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_BEFORE_COMBAT); + } + + public void yieldUntilEndStep() { + getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_END_STEP); + } + + public void yieldUntilEndOfTurn() { + getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_END_OF_TURN); + } + public int getPlayerCount() { return getGui().getPlayerCount(); } From 9595faf22f26e1f7d03d4c625588634b23711ee7 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Thu, 29 Jan 2026 21:18:27 +1030 Subject: [PATCH 09/33] Fix yield timing for End Step and Your Turn buttons - Add turn/phase tracking for UNTIL_END_STEP yield mode (same pattern as combat) - Add tracking for UNTIL_YOUR_NEXT_TURN to handle clicks during own turn - Rename "End Turn" button to "Next Turn" for clarity - Update tooltips to accurately describe yield behavior - Update documentation to reflect new tracking logic Co-Authored-By: Claude Opus 4.5 --- .documentation/YieldRework-PR.md | 28 +++-- DOCUMENTATION.md | 108 +++++++++++++--- forge-gui/res/languages/en-US.properties | 15 +++ .../gamemodes/match/AbstractGuiGame.java | 118 ++++++++++++++++-- 4 files changed, 232 insertions(+), 37 deletions(-) diff --git a/.documentation/YieldRework-PR.md b/.documentation/YieldRework-PR.md index 2595fd84f1b..f55cf499c47 100644 --- a/.documentation/YieldRework-PR.md +++ b/.documentation/YieldRework-PR.md @@ -30,14 +30,15 @@ Extended yield options that automatically pass priority until specific condition ### Yield Modes | Mode | End Condition | Hotkey | |------|---------------|--------| -| Until End of Turn | Turn number changes | F1 | +| Next Turn | Turn number changes | F1 | | Until Stack Clears | Stack empty (including simultaneous triggers) | F2 | -| Until Before Combat | COMBAT_BEGIN phase or later | F3 | -| Until End Step | END_OF_TURN or CLEANUP phase | F4 | -| Until Your Next Turn | Your turn starts (3+ players only) | F5 | +| Until Before Combat | Next COMBAT_BEGIN phase (tracks start turn/phase) | F3 | +| Until End Step | Next END_OF_TURN phase (tracks start turn/phase) | F4 | +| Until Your Next Turn | Your turn starts again (tracks if started during own turn) | F5 | ### Access Methods -- Right-click "End Turn" button for yield options menu +- **Yield Options Panel**: Dockable panel with dedicated yield buttons (appears with Stack panel) +- Right-click "End Turn" button for yield options menu (configurable) - Keyboard shortcuts: F1-F5 for yield modes, ESC to cancel - Game menu → Yield Options submenu @@ -53,15 +54,19 @@ Prompts appear when player likely cannot act: - **You or your permanents** targeted - Any opponent spell cast - Combat begins +- Cards revealed (can be disabled to auto-dismiss reveal dialogs) ## Files Changed -**New (1):** -- `forge-gui/.../YieldMode.java` +**New (3):** +- `forge-gui/.../YieldMode.java` - Yield mode enum +- `forge-gui-desktop/.../VYield.java` - Yield panel view +- `forge-gui-desktop/.../CYield.java` - Yield panel controller -**Modified (12):** +**Modified (15):** - `forge-gui`: AbstractGuiGame, InputPassPriority, IGuiGame, IGameController, PlayerControllerHuman, ForgePreferences, NetGameController, ProtocolMethod, en-US.properties -- `forge-gui-desktop`: VPrompt, GameMenu, KeyboardShortcuts +- `forge-gui-desktop`: VPrompt, VMatchUI, CMatchUI, EDocID, FButton, GameMenu, KeyboardShortcuts +- `forge-gui/res`: match.xml (default layout) ## How to Enable @@ -77,6 +82,11 @@ Prompts appear when player likely cannot act: - [ ] Smart suggestions appear in prompt area (not modal dialogs) - [ ] Menu checkboxes stay open when toggled - [ ] Network play: no desync with extended yields +- [ ] Yield Options panel appears when feature enabled +- [ ] Yield buttons disabled during mulligan +- [ ] Active yield button highlighted in red +- [ ] "Interrupt on Reveal" setting works (dialogs skipped when disabled) +- [ ] Combat yield stops at correct combat (not same turn's M2) ## Risk Assessment diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a9cce446a73..b8e11efba15 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -21,17 +21,28 @@ Extended yield options that allow players to automatically pass priority until s | Mode | Description | End Condition | Availability | |------|-------------|---------------|--------------| -| Until End of Turn | Auto-pass until end of current turn | Turn number changes | Always | +| Next Turn | Auto-pass until next turn | Turn number changes | Always | | Until Stack Clears | Auto-pass while stack has items | Stack becomes empty (including simultaneous triggers) | Always | -| Until Before Combat | Auto-pass until combat begins | COMBAT_BEGIN phase or later | Always | -| Until End Step | Auto-pass until end step | END_OF_TURN or CLEANUP phase | Always | -| Until Your Next Turn | Auto-pass until you become active player | Your turn starts | 3+ player games only | +| Until Before Combat | Auto-pass until combat begins | Next COMBAT_BEGIN phase (tracks start turn/phase) | Always | +| Until End Step | Auto-pass until end step | Next END_OF_TURN phase (tracks start turn/phase) | Always | +| Until Your Next Turn | Auto-pass until you become active player | Your turn starts again (tracks if started during own turn) | 3+ player games only | ### Access Methods -1. **Right-Click Menu**: Right-click the "End Turn" button to see yield options -2. **Keyboard Shortcuts** (F-keys to avoid conflict with ability selection 1-9): - - `F1` - Yield until end of turn +1. **Yield Options Panel**: A dockable panel with dedicated yield buttons: + - **Clear Stack** - Yield until stack clears (only enabled when stack has items) + - **Combat** - Yield until before combat + - **End Step** - Yield until end step + - **Next Turn** - Yield until next turn + - **Your Turn** - Yield until your next turn (only visible in 3+ player games) + - Buttons are blue by default, red when that yield mode is active + - Panel appears as a tab alongside the Stack panel when experimental yields are enabled + - Buttons are disabled during mulligan and pre-game phases + +2. **Right-Click Menu**: Right-click the "End Turn" button to see yield options (configurable) + +3. **Keyboard Shortcuts** (F-keys to avoid conflict with ability selection 1-9): + - `F1` - Yield until next turn - `F2` - Yield until stack clears - `F3` - Yield until before combat - `F4` - Yield until end step @@ -59,6 +70,7 @@ Yield modes can be configured to automatically cancel when: - **You or your permanents** are targeted by a spell/ability (default: ON) - An opponent casts any spell (default: OFF) - Combat begins (default: OFF) +- Cards are revealed (default: ON) - when disabled, reveal dialogs are auto-dismissed during yield **Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. @@ -95,8 +107,14 @@ forge-gui/ (shared GUI code) └── en-US.properties # Localization forge-gui-desktop/ (desktop-specific) +├── VYield.java # Yield Options panel view (NEW) +├── CYield.java # Yield Options panel controller (NEW) +├── EDocID.java # Added REPORT_YIELD doc ID ├── VPrompt.java # Right-click menu +├── VMatchUI.java # Dynamic panel visibility +├── CMatchUI.java # Yield panel registration ├── GameMenu.java # Yield Options submenu +├── FButton.java # Added highlight mode for buttons └── KeyboardShortcuts.java # New shortcuts ``` @@ -132,6 +150,11 @@ The "End Turn" button (Cancel button during priority) has different behavior dep // In AbstractGuiGame.java private final Map playerYieldMode = Maps.newHashMap(); private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set +private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set +private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? +private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set +private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? +private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? ``` The `shouldAutoYieldForPlayer()` method checks: @@ -141,33 +164,42 @@ The `shouldAutoYieldForPlayer()` method checks: 4. Mode-specific end conditions: - `UNTIL_STACK_CLEARS`: Clears when stack is empty AND no simultaneous stack entries - `UNTIL_END_OF_TURN`: Clears when turn number changes (tracked via `yieldStartTurn`) - - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes the active player - - `UNTIL_BEFORE_COMBAT`: Clears at COMBAT_BEGIN phase or any phase after - - `UNTIL_END_STEP`: Clears at END_OF_TURN or CLEANUP phase + - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes active player; if started during own turn, waits until turn comes back around + - `UNTIL_BEFORE_COMBAT`: Clears at next COMBAT_BEGIN; if started at/after combat, waits for next turn's combat + - `UNTIL_END_STEP`: Clears at next END_OF_TURN; if started at/after end step, waits for next turn's end step ## Files Changed -### New Files (1) -- `forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java` +### New Files (3) +- `forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java` - Yield mode enum +- `forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java` - Yield panel view +- `forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java` - Yield panel controller -### Modified Files (12) +### Modified Files (14) -**forge-gui (8 files):** -- `AbstractGuiGame.java` - Yield mode tracking, interrupt logic +**forge-gui (9 files):** +- `AbstractGuiGame.java` - Yield mode tracking, interrupt logic, combat yield tracking - `InputPassPriority.java` - Smart suggestion prompts - `IGuiGame.java` - Interface methods - `IGameController.java` - Controller interface -- `PlayerControllerHuman.java` - Controller implementation -- `ForgePreferences.java` - 11 new preferences +- `PlayerControllerHuman.java` - Controller implementation, reveal skip during yield +- `ForgePreferences.java` - 13 new preferences - `NetGameController.java` - Network protocol implementation - `ProtocolMethod.java` - Protocol enum values -- `en-US.properties` - 25+ localization strings +- `en-US.properties` - 30+ localization strings -**forge-gui-desktop (3 files):** +**forge-gui-desktop (7 files):** - `VPrompt.java` - Right-click menu on End Turn button -- `GameMenu.java` - Yield Options submenu +- `VMatchUI.java` - Dynamic panel visibility based on preferences +- `CMatchUI.java` - Yield panel registration and updates +- `EDocID.java` - Added REPORT_YIELD document ID +- `FButton.java` - Added highlight mode for yield button coloring +- `GameMenu.java` - Yield Options submenu with Display Options - `KeyboardShortcuts.java` - New keyboard shortcuts +**Resources (1):** +- `match.xml` - Added REPORT_YIELD to default layout + ## New Preferences ```java @@ -185,6 +217,10 @@ YIELD_INTERRUPT_ON_BLOCKERS("true") YIELD_INTERRUPT_ON_TARGETING("true") YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") YIELD_INTERRUPT_ON_COMBAT("false") +YIELD_INTERRUPT_ON_REVEAL("true") + +// Display options +YIELD_SHOW_RIGHT_CLICK_MENU("false") // Right-click menu on End Turn button // Keyboard shortcuts (F-keys) SHORTCUT_YIELD_UNTIL_END_OF_TURN("112") // F1 @@ -240,12 +276,18 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 - [ ] Spells targeting other players does NOT cancel your yield - [ ] "Opponent spell" only triggers for spells and activated abilities, NOT triggered abilities - Triggered abilities that target you are handled by the "targeting" interrupt instead +- [ ] Reveal dialogs interrupt yield when "Interrupt on Reveal" is ON (default) +- [ ] Reveal dialogs auto-dismissed when "Interrupt on Reveal" is OFF - [ ] Each interrupt respects its toggle setting #### Visual Feedback - [ ] Prompt area shows "Yielding until..." message - [ ] Cancel button allows breaking out of yield - [ ] Yield Options submenu checkboxes stay open when toggled (menu doesn't close) +- [ ] Yield Options panel appears as tab with Stack panel +- [ ] Active yield button highlighted in red, others blue +- [ ] Yield buttons disabled during mulligan/pre-game phases +- [ ] "Clear Stack" button disabled when stack is empty #### Network Play - [ ] Yield modes sync correctly between clients @@ -265,6 +307,32 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 ## Changelog +### 2026-01-29 - Yield Options Panel & Reveal Interrupt Setting + +**New Features:** +1. **Yield Options Panel** - A dedicated dockable panel with yield control buttons: + - Appears as a tab alongside the Stack panel when experimental yields are enabled + - Contains buttons: Clear Stack, Combat, End Step, End Turn, Your Turn + - Buttons use highlight mode: blue (normal), red (active yield mode) + - "Your Turn" button only visible in 3+ player games + - "Clear Stack" only enabled when stack has items + - All buttons disabled during mulligan and pre-game phases + +2. **Interrupt on Reveal setting** - New interrupt option under Yield Options > Interrupt Settings: + - "When cards are revealed" (default: ON) + - When disabled, reveal dialogs are auto-dismissed during active yield + - Useful for avoiding interrupts when opponents tutor or reveal cards + +3. **Display Options submenu** - New submenu under Yield Options: + - "Show Right-Click Menu" - Toggle right-click yield menu on End Turn button (default: OFF) + +**Technical Changes:** +1. **FButton highlight mode** - Added `setUseHighlightMode()` and `setHighlighted()` to FButton for inverted color scheme (blue default, red when active) + +2. **Combat yield tracking** - Fixed issue where clicking Combat during an existing combat phase would skip past the next combat. Now tracks turn number and whether yield started at/after combat. + +3. **Panel visibility** - Yield Options panel dynamically shown/hidden based on `YIELD_EXPERIMENTAL_OPTIONS` preference + ### 2026-01-29 - New Yield Modes and F-Key Hotkeys **New Features:** diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index dd33b6fcfac..b068733e0ce 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1547,9 +1547,24 @@ lblInterruptOnBlockers=When you can declare blockers lblInterruptOnTargeting=When targeted by spell or ability lblInterruptOnOpponentSpell=When opponent casts a spell lblInterruptOnCombat=At beginning of combat +lblInterruptOnReveal=When cards are revealed lblSuggestStackYield=When can't respond to stack lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available +lblDisplayOptions=Display Options +lblShowRightClickMenu=Show Right-Click Menu +lblYieldBtnClearStack=Clear Stack +lblYieldBtnCombat=Combat +lblYieldBtnEndStep=End Step +lblYieldBtnYourTurn=Your Turn +lblYieldBtnClearStackTooltip=Pass priority until the stack is empty. +lblYieldBtnCombatTooltip=Pass priority until the combat phase begins. +lblYieldBtnEndStepTooltip=Pass priority until the end step. +lblYieldBtnYourTurnTooltip=Pass priority until YOUR next turn. +lblYieldBtnEndTurn=Next Turn +lblYieldBtnEndTurnTooltip=Pass priority until next turn. +lblYield=Yield +lblYieldOptions=Yield Options lblSHORTCUT_YIELD_UNTIL_END_OF_TURN=Yield Until End of Turn lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS=Yield Until Stack Clears lblSHORTCUT_YIELD_UNTIL_BEFORE_COMBAT=Yield Until Combat 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 d4376d80c92..19576f72a5b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -419,6 +419,11 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final // Extended yield mode tracking (experimental feature) private final Map playerYieldMode = Maps.newHashMap(); private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set + private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set + private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? + private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set + private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? + private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? /** * Automatically pass priority until reaching the Cleanup phase of the @@ -558,6 +563,31 @@ public final void setYieldMode(final PlayerView player, final YieldMode mode) { if (mode == YieldMode.UNTIL_END_OF_TURN && getGameView() != null && getGameView().getGame() != null) { yieldStartTurn.put(player, getGameView().getGame().getPhaseHandler().getTurn()); } + // Track turn and phase state for UNTIL_BEFORE_COMBAT mode + if (mode == YieldMode.UNTIL_BEFORE_COMBAT && getGameView() != null && getGameView().getGame() != null) { + forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); + yieldCombatStartTurn.put(player, ph.getTurn()); + forge.game.phase.PhaseType phase = ph.getPhase(); + boolean atOrAfterCombat = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); + } + // Track turn and phase state for UNTIL_END_STEP mode + if (mode == YieldMode.UNTIL_END_STEP && getGameView() != null && getGameView().getGame() != null) { + forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); + yieldEndStepStartTurn.put(player, ph.getTurn()); + forge.game.phase.PhaseType phase = ph.getPhase(); + boolean atOrAfterEndStep = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); + } + // Track if UNTIL_YOUR_NEXT_TURN was started during our turn + if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN && getGameView() != null && getGameView().getGame() != null) { + forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); + forge.game.player.Player playerObj = getGameView().getGame().getPlayer(player); + boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); + } updateAutoPassPrompt(); } @@ -565,6 +595,11 @@ public final void setYieldMode(final PlayerView player, final YieldMode mode) { public final void clearYieldMode(final PlayerView player) { playerYieldMode.remove(player); yieldStartTurn.remove(player); + yieldCombatStartTurn.remove(player); + yieldCombatStartedAtOrAfterCombat.remove(player); + yieldEndStepStartTurn.remove(player); + yieldEndStepStartedAtOrAfterEndStep.remove(player); + yieldYourTurnStartedDuringOurTurn.remove(player); autoPassUntilEndOfTurn.remove(player); // Legacy compatibility showPromptMessage(player, ""); @@ -639,25 +674,92 @@ public final boolean shouldAutoYieldForPlayer(final PlayerView player) { // Yield until our turn starts forge.game.player.Player playerObj = game.getPlayer(player); boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + Boolean startedDuringOurTurn = yieldYourTurnStartedDuringOurTurn.get(player); + + if (startedDuringOurTurn == null) { + // Tracking wasn't set - initialize it now + yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); + startedDuringOurTurn = isOurTurn; + } + if (isOurTurn) { - clearYieldMode(player); - yield false; + // If we started during our turn, we need to wait until it's our turn AGAIN + // (i.e., we left our turn and came back) + // If we started during opponent's turn, stop when we reach our turn + if (!Boolean.TRUE.equals(startedDuringOurTurn)) { + clearYieldMode(player); + yield false; + } + } else { + // Not our turn - if we started during our turn, mark that we've left it + if (Boolean.TRUE.equals(startedDuringOurTurn)) { + // We've left our turn, now waiting for it to come back + yieldYourTurnStartedDuringOurTurn.put(player, false); + } } yield true; } case UNTIL_BEFORE_COMBAT -> { forge.game.phase.PhaseType phase = ph.getPhase(); - if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)) { - clearYieldMode(player); - yield false; + Integer startTurn = yieldCombatStartTurn.get(player); + Boolean startedAtOrAfterCombat = yieldCombatStartedAtOrAfterCombat.get(player); + int currentTurn = ph.getTurn(); + + if (startTurn == null) { + // Tracking wasn't set - initialize it now + yieldCombatStartTurn.put(player, currentTurn); + boolean atOrAfterCombat = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); + startTurn = currentTurn; + startedAtOrAfterCombat = atOrAfterCombat; + } + + // Check if we should stop: we're at or past combat on a DIFFERENT turn than when we started, + // OR we're at combat on the SAME turn but we started BEFORE combat + boolean atOrAfterCombatNow = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + + if (atOrAfterCombatNow) { + boolean differentTurn = currentTurn > startTurn; + boolean sameTurnButStartedBeforeCombat = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterCombat); + + if (differentTurn || sameTurnButStartedBeforeCombat) { + clearYieldMode(player); + yield false; + } } yield true; } case UNTIL_END_STEP -> { forge.game.phase.PhaseType phase = ph.getPhase(); - if (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP) { - clearYieldMode(player); - yield false; + Integer startTurn = yieldEndStepStartTurn.get(player); + Boolean startedAtOrAfterEndStep = yieldEndStepStartedAtOrAfterEndStep.get(player); + int currentTurn = ph.getTurn(); + + if (startTurn == null) { + // Tracking wasn't set - initialize it now + yieldEndStepStartTurn.put(player, currentTurn); + boolean atOrAfterEndStep = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); + startTurn = currentTurn; + startedAtOrAfterEndStep = atOrAfterEndStep; + } + + // Check if we should stop: we're at or past end step on a DIFFERENT turn than when we started, + // OR we're at end step on the SAME turn but we started BEFORE end step + boolean atOrAfterEndStepNow = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + + if (atOrAfterEndStepNow) { + boolean differentTurn = currentTurn > startTurn; + boolean sameTurnButStartedBeforeEndStep = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterEndStep); + + if (differentTurn || sameTurnButStartedBeforeEndStep) { + clearYieldMode(player); + yield false; + } } yield true; } From 2eb6bc0b7699386f95ffb737705f00bf33862b5f Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Thu, 29 Jan 2026 21:52:04 +1030 Subject: [PATCH 10/33] Add auto-suppress for declined suggestions and bug fixes - Auto-suppress declined suggestions until next turn with hint text - Fix PlayerView instance matching in yield methods (map key bug) - Add yield button priority over smart suggestions - Extend reveal interrupt to cover opponent choices - Disable yield buttons during cleanup/discard phase - Add isBeingAttacked helper for planeswalker/battle attacks - Change YIELD_INTERRUPT_ON_REVEAL default to false - Update documentation with all changes Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 43 +++- .../main/java/forge/gui/framework/EDocID.java | 1 + .../java/forge/screens/match/CMatchUI.java | 19 +- .../java/forge/screens/match/VMatchUI.java | 23 ++ .../screens/match/controllers/CYield.java | 228 ++++++++++++++++++ .../forge/screens/match/menus/GameMenu.java | 6 + .../forge/screens/match/views/VPrompt.java | 5 +- .../forge/screens/match/views/VYield.java | 134 ++++++++++ .../src/main/java/forge/toolbox/FButton.java | 57 +++++ forge-gui/res/defaults/match.xml | 1 + forge-gui/res/languages/en-US.properties | 3 +- .../gamemodes/match/AbstractGuiGame.java | 84 ++++++- .../match/input/InputPassPriority.java | 43 +++- .../java/forge/gui/interfaces/IGuiGame.java | 5 + .../properties/ForgePreferences.java | 2 + .../forge/player/PlayerControllerHuman.java | 25 ++ 16 files changed, 657 insertions(+), 22 deletions(-) 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 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b8e11efba15..c78051dcdd7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -70,7 +70,7 @@ Yield modes can be configured to automatically cancel when: - **You or your permanents** are targeted by a spell/ability (default: ON) - An opponent casts any spell (default: OFF) - Combat begins (default: OFF) -- Cards are revealed (default: ON) - when disabled, reveal dialogs are auto-dismissed during yield +- Cards are revealed or choices are made (default: OFF) - when disabled, reveal dialogs and opponent choice notifications are auto-dismissed during yield **Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. @@ -155,6 +155,10 @@ private final Map yieldCombatStartedAtOrAfterCombat = Maps. private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? + +// Smart suggestion decline tracking (resets each turn) +private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); +private final Map declinedSuggestionsTurn = Maps.newHashMap(); ``` The `shouldAutoYieldForPlayer()` method checks: @@ -217,7 +221,7 @@ YIELD_INTERRUPT_ON_BLOCKERS("true") YIELD_INTERRUPT_ON_TARGETING("true") YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") YIELD_INTERRUPT_ON_COMBAT("false") -YIELD_INTERRUPT_ON_REVEAL("true") +YIELD_INTERRUPT_ON_REVEAL("false") // Also covers opponent choices // Display options YIELD_SHOW_RIGHT_CLICK_MENU("false") // Right-click menu on End Turn button @@ -267,6 +271,10 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 - [ ] Each suggestion respects its individual toggle - [ ] Accept button activates yield mode - [ ] Decline button shows normal priority prompt +- [ ] **Declined suggestions are suppressed** - After declining, same suggestion type does NOT appear again on same turn +- [ ] **Suppression resets on turn change** - Declined suggestions can appear again on next turn +- [ ] **Hint text shown** - "(Declining disables this prompt until next turn)" appears in suggestion prompt +- [ ] **Yield buttons override suggestions** - Clicking a yield button while suggestion is showing activates the clicked yield, not the suggested one #### Interrupts - [ ] Attackers declared against you cancels yield @@ -276,8 +284,9 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 - [ ] Spells targeting other players does NOT cancel your yield - [ ] "Opponent spell" only triggers for spells and activated abilities, NOT triggered abilities - Triggered abilities that target you are handled by the "targeting" interrupt instead -- [ ] Reveal dialogs interrupt yield when "Interrupt on Reveal" is ON (default) -- [ ] Reveal dialogs auto-dismissed when "Interrupt on Reveal" is OFF +- [ ] Reveal dialogs interrupt yield when "Interrupt on Reveal/Choices" is ON +- [ ] Reveal dialogs auto-dismissed when "Interrupt on Reveal/Choices" is OFF (default) +- [ ] Opponent choice notifications (e.g., Unclaimed Territory) auto-dismissed when setting is OFF - [ ] Each interrupt respects its toggle setting #### Visual Feedback @@ -287,6 +296,7 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 - [ ] Yield Options panel appears as tab with Stack panel - [ ] Active yield button highlighted in red, others blue - [ ] Yield buttons disabled during mulligan/pre-game phases +- [ ] Yield buttons disabled during cleanup/discard phase - [ ] "Clear Stack" button disabled when stack is empty #### Network Play @@ -307,6 +317,31 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 ## Changelog +### 2026-01-29 - Auto-Suppress Suggestions & Bug Fixes + +**New Features:** +1. **Auto-suppress declined suggestions** - When a smart yield suggestion is declined, that suggestion type is automatically suppressed for the rest of the turn. At turn change, suppression resets. A hint is now shown: "(Declining disables this prompt until next turn)" + +2. **Yield button priority over suggestions** - Clicking a yield button while a smart suggestion is showing now properly activates the selected yield mode instead of the suggested one. + +3. **Extended reveal interrupt** - The "interrupt on reveal" setting now also covers opponent choices (e.g., Unclaimed Territory creature type selection). Label updated to "When cards revealed or choices made". + +4. **Yield buttons disabled during discard** - Yield buttons are now greyed out and disabled during the cleanup/discard phase, similar to mulligan. + +**Bug Fixes:** +1. **PlayerView instance matching** - Added `TrackableTypes.PlayerViewType.lookup(player)` to all yield-related methods (`setYieldMode`, `clearYieldMode`, `getYieldMode`, `shouldAutoYieldForPlayer`, `declineSuggestion`, `isSuggestionDeclined`). This fixes potential map key mismatches that could cause yield modes to not be tracked correctly. + +2. **Combat interrupt scoping** - Added null check for player lookup and improved `isBeingAttacked()` helper that checks if the player OR their planeswalkers/battles are being attacked. This prevents interrupts when other players are attacked in multiplayer. + +3. **Default for reveal interrupt** - Changed `YIELD_INTERRUPT_ON_REVEAL` default from `true` to `false` to reduce interruptions. + +**Technical Changes:** +- Added `declineSuggestion()` and `isSuggestionDeclined()` methods to `IGuiGame` interface and `AbstractGuiGame` +- Added `declinedSuggestionsThisTurn` and `declinedSuggestionsTurn` tracking maps +- Added `pendingSuggestionType` field to `InputPassPriority` +- Added yield check to `notifyOfValue()` in `PlayerControllerHuman` +- Added cleanup phase check to `canYieldNow()` in `CYield` + ### 2026-01-29 - Yield Options Panel & Reveal Interrupt Setting **New Features:** 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 6bb6b0d898b..7bcbc07534c 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 @@ -90,6 +90,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/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index eb0c753b8be..f1b1eda9f08 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 @@ -109,6 +109,7 @@ import forge.screens.match.controllers.CLog; import forge.screens.match.controllers.CPrompt; import forge.screens.match.controllers.CStack; +import forge.screens.match.controllers.CYield; import forge.screens.match.menus.CMatchUIMenus; import forge.screens.match.views.VField; import forge.screens.match.views.VHand; @@ -174,6 +175,7 @@ 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); private int nextNotifiableStackIndex = 0; public CMatchUI() { @@ -193,6 +195,12 @@ 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()); + // Only create yield panel if experimental options are enabled + if (isPreferenceEnabled(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + this.myDocs.put(EDocID.REPORT_YIELD, getCYield().getView()); + } else { + this.myDocs.put(EDocID.REPORT_YIELD, null); + } this.myDocs.put(EDocID.DEV_MODE, getCDev().getView()); this.myDocs.put(EDocID.BUTTON_DOCK, getCDock().getView()); } @@ -263,6 +271,9 @@ CPrompt getCPrompt() { public CStack getCStack() { return cStack; } + public CYield getCYield() { + return cYield; + } public TargetingOverlay getTargetingOverlay() { return targetingOverlay; } @@ -724,6 +735,9 @@ public void updateButtons(final PlayerView owner, final String label1, final Str btn1.setText(label1); btn2.setText(label2); + // Update yield buttons state when prompt changes (e.g., entering/exiting mulligan) + getCYield().updateYieldButtons(); + final FButton toFocus = enable1 && focus1 ? btn1 : (enable2 ? btn2 : null); //pfps This seems wrong so I've commented it out for now and put a replacement in the runnable @@ -831,7 +845,10 @@ public void finishGame() { @Override public void updateStack() { - FThreads.invokeInEdtNowOrLater(() -> getCStack().update()); + FThreads.invokeInEdtNowOrLater(() -> { + getCStack().update(); + getCYield().updateYieldButtons(); // Update yield button states + }); } /** diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java index 65bb88aae24..d760b101f89 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java @@ -13,8 +13,10 @@ import forge.gui.framework.SRearrangingUtil; import forge.gui.framework.VEmptyDoc; import forge.localinstance.properties.ForgePreferences; +import forge.model.FModel; import forge.screens.match.views.VDev; import forge.screens.match.views.VField; +import forge.screens.match.views.VYield; import forge.screens.match.views.VHand; import forge.sound.MusicPlaylist; import forge.sound.SoundSystem; @@ -63,6 +65,27 @@ public void populate() { getControl().getCPrompt().getView().getParentCell().addDoc(vDev); } + // Yield panel - only show when experimental yield options are enabled + final VYield vYield = getControl().getCYield().getView(); + final boolean yieldEnabled = FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); + if (!yieldEnabled) { + if (vYield.getParentCell() != null) { + final DragCell parent = vYield.getParentCell(); + parent.removeDoc(vYield); + vYield.setParentCell(null); + + if (parent.getDocs().size() > 0) { + parent.setSelected(parent.getDocs().get(0)); + } + } + } else if (vYield.getParentCell() == null) { + // Yield enabled but not in layout - add to stack cell by default + final DragCell stackCell = EDocID.REPORT_STACK.getDoc().getParentCell(); + if (stackCell != null) { + stackCell.addDoc(vYield); + } + } + //focus first enabled Prompt button if returning to match screen if (getBtnOK().isEnabled()) { getBtnOK().requestFocusInWindow(); 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..e3c168e4c51 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java @@ -0,0 +1,228 @@ +/* + * 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.game.GameView; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMode; +import forge.gui.framework.ICDoc; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.CMatchUI; +import forge.screens.match.views.VYield; + +/** + * 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; + + // Cache multiplayer state (doesn't change during game) + private boolean isMultiplayer = false; + + // Yield button action listeners + private final ActionListener actClearStack = evt -> yieldUntilStackClears(); + private final ActionListener actCombat = evt -> yieldUntilCombat(); + private final ActionListener actEndStep = evt -> yieldUntilEndStep(); + private final ActionListener actEndTurn = evt -> yieldUntilEndTurn(); + private final ActionListener actYourTurn = evt -> yieldUntilYourTurn(); + + 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() { + // Cache multiplayer state once + isMultiplayer = matchUI.getPlayerCount() >= 3; + + // Initialize button action listeners + initButton(view.getBtnClearStack(), actClearStack); + initButton(view.getBtnCombat(), actCombat); + initButton(view.getBtnEndStep(), actEndStep); + initButton(view.getBtnEndTurn(), actEndTurn); + initButton(view.getBtnYourTurn(), actYourTurn); + + // Set initial button state + updateYieldButtons(); + } + + private void initButton(final JButton button, final ActionListener onClick) { + button.removeActionListener(onClick); + button.addActionListener(onClick); + } + + @Override + public void update() { + updateYieldButtons(); + } + + // Yield action methods + private void yieldUntilStackClears() { + if (matchUI != null && matchUI.getGameController() != null) { + matchUI.getGameController().yieldUntilStackClears(); + matchUI.getGameController().selectButtonOk(); + } + } + + private void yieldUntilCombat() { + if (matchUI != null && matchUI.getGameController() != null) { + matchUI.getGameController().yieldUntilBeforeCombat(); + matchUI.getGameController().selectButtonOk(); + } + } + + private void yieldUntilEndStep() { + if (matchUI != null && matchUI.getGameController() != null) { + matchUI.getGameController().yieldUntilEndStep(); + matchUI.getGameController().selectButtonOk(); + } + } + + private void yieldUntilEndTurn() { + if (matchUI != null && matchUI.getGameController() != null) { + matchUI.getGameController().passPriorityUntilEndOfTurn(); + } + } + + private void yieldUntilYourTurn() { + if (matchUI != null && matchUI.getGameController() != null) { + matchUI.getGameController().yieldUntilYourNextTurn(); + matchUI.getGameController().selectButtonOk(); + } + } + + /** + * Update yield buttons enabled state based on game state. + * Buttons are disabled during mulligan, sideboarding, and game over. + * Active yield mode button is highlighted (toggled). + */ + public void updateYieldButtons() { + ForgePreferences prefs = FModel.getPreferences(); + + // Check if experimental yield options are enabled + boolean yieldEnabled = prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + + // Check if we can yield (not in mulligan, sideboard, or game over) + boolean canYield = yieldEnabled && canYieldNow(); + + // Enable/disable all yield buttons based on whether we can yield + view.getBtnCombat().setEnabled(canYield); + view.getBtnEndStep().setEnabled(canYield); + view.getBtnEndTurn().setEnabled(canYield); + view.getBtnYourTurn().setEnabled(canYield); + + // Clear Stack also requires items on stack + boolean stackHasItems = matchUI.getGameView() != null + && matchUI.getGameView().getStack() != null + && !matchUI.getGameView().getStack().isEmpty(); + view.getBtnClearStack().setEnabled(canYield && stackHasItems); + + // Show/hide Your Turn based on player count (only for 3+ players) + view.getBtnYourTurn().setVisible(isMultiplayer); + + // Highlight active yield button + updateActiveYieldHighlight(); + } + + /** + * Update button highlight state to show the currently active yield mode. + * Active yield button is highlighted (red), others are normal (blue). + */ + private void updateActiveYieldHighlight() { + // Get current yield mode for the current player + YieldMode currentMode = YieldMode.NONE; + PlayerView currentPlayer = matchUI.getCurrentPlayer(); + if (currentPlayer != null) { + currentMode = matchUI.getYieldMode(currentPlayer); + } + + // Set highlight state based on active yield mode + // Highlighted = red (active), not highlighted = blue (normal) + view.getBtnClearStack().setHighlighted(currentMode == YieldMode.UNTIL_STACK_CLEARS); + view.getBtnCombat().setHighlighted(currentMode == YieldMode.UNTIL_BEFORE_COMBAT); + view.getBtnEndStep().setHighlighted(currentMode == YieldMode.UNTIL_END_STEP); + view.getBtnEndTurn().setHighlighted(currentMode == YieldMode.UNTIL_END_OF_TURN); + view.getBtnYourTurn().setHighlighted(currentMode == YieldMode.UNTIL_YOUR_NEXT_TURN); + } + + /** + * Check if we're in a state where yielding makes sense. + * Returns false during mulligan, sideboarding, game over, cleanup/discard, etc. + */ + private boolean canYieldNow() { + GameView gameView = matchUI.getGameView(); + if (gameView == null) { + return false; + } + + // Can't yield if game is over + if (gameView.isGameOver()) { + return false; + } + + // Can't yield during mulligan (explicit flag) + if (gameView.isMulligan()) { + return false; + } + + // Can't yield if game hasn't started yet (turn 0 = pre-game/mulligan phase) + if (gameView.getTurn() < 1) { + return false; + } + + // Can't yield if no phase set (game not fully started) + if (gameView.getPhase() == null) { + return false; + } + + // Can't yield during cleanup phase (when discarding to hand size) + if (gameView.getPhase() == forge.game.phase.PhaseType.CLEANUP) { + return false; + } + + // Can't yield if no game controller + if (matchUI.getGameController() == null) { + return false; + } + + return true; + } +} 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 a0ff36f7ffe..ad84d05ef4b 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 @@ -225,6 +225,7 @@ private JMenu getYieldOptionsMenu() { interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnCombat"), FPref.YIELD_INTERRUPT_ON_COMBAT)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL)); yieldMenu.add(interruptMenu); // Sub-menu 2: Automatic Suggestions @@ -234,6 +235,11 @@ private JMenu getYieldOptionsMenu() { suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestNoActions"), FPref.YIELD_SUGGEST_NO_ACTIONS)); yieldMenu.add(suggestionsMenu); + // Sub-menu 3: Display Options + final JMenu displayMenu = new JMenu(localizer.getMessage("lblDisplayOptions")); + displayMenu.add(createYieldCheckbox(localizer.getMessage("lblShowRightClickMenu"), FPref.YIELD_SHOW_RIGHT_CLICK_MENU)); + yieldMenu.add(displayMenu); + return yieldMenu; } 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 228baf10c6d..3ad90431726 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 @@ -70,7 +70,7 @@ public class VPrompt implements IVDoc { private final FScrollPane messageScroller = new FScrollPane(tarMessage, false, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); private final JLabel lblGames; - private CardView card = null ; + private CardView card = null ; public void setCardView(final CardView card) { this.card = card ; @@ -123,7 +123,8 @@ public VPrompt(final CPrompt controller) { btnCancel.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { - if (SwingUtilities.isRightMouseButton(e) && isYieldExperimentalEnabled()) { + if (SwingUtilities.isRightMouseButton(e) && isYieldExperimentalEnabled() + && FModel.getPreferences().getPrefBoolean(FPref.YIELD_SHOW_RIGHT_CLICK_MENU)) { showYieldOptionsMenu(e); } } 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..9e91c0f4f9c --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java @@ -0,0 +1,134 @@ +/* + * 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 { + + // Fields used with interface IVDoc + private DragCell parentCell; + private final Localizer localizer = Localizer.getInstance(); + private final DragTab tab = new DragTab(localizer.getMessage("lblYieldOptions")); + + // Yield control buttons + private final FButton btnClearStack = new FButton(localizer.getMessage("lblYieldBtnClearStack")); + private final FButton btnCombat = new FButton(localizer.getMessage("lblYieldBtnCombat")); + private final FButton btnEndStep = new FButton(localizer.getMessage("lblYieldBtnEndStep")); + private final FButton btnEndTurn = new FButton(localizer.getMessage("lblYieldBtnEndTurn")); + private final FButton btnYourTurn = new FButton(localizer.getMessage("lblYieldBtnYourTurn")); + + private final CYield controller; + + public VYield(final CYield controller) { + this.controller = controller; + + // Use smaller font to fit button text + java.awt.Font smallFont = FSkin.getBoldFont(11).getBaseFont(); + btnClearStack.setFont(smallFont); + btnCombat.setFont(smallFont); + btnEndStep.setFont(smallFont); + btnEndTurn.setFont(smallFont); + btnYourTurn.setFont(smallFont); + + // Enable highlight mode: blue by default, red when active yield + btnClearStack.setUseHighlightMode(true); + btnCombat.setUseHighlightMode(true); + btnEndStep.setUseHighlightMode(true); + btnEndTurn.setUseHighlightMode(true); + btnYourTurn.setUseHighlightMode(true); + + // Set tooltips on yield buttons + btnClearStack.setToolTipText(localizer.getMessage("lblYieldBtnClearStackTooltip")); + btnCombat.setToolTipText(localizer.getMessage("lblYieldBtnCombatTooltip")); + btnEndStep.setToolTipText(localizer.getMessage("lblYieldBtnEndStepTooltip")); + btnEndTurn.setToolTipText(localizer.getMessage("lblYieldBtnEndTurnTooltip")); + btnYourTurn.setToolTipText(localizer.getMessage("lblYieldBtnYourTurnTooltip")); + } + + @Override + public void populate() { + JPanel container = parentCell.getBody(); + + boolean largerButtons = FModel.getPreferences().getPrefBoolean(FPref.UI_FOR_TOUCHSCREN); + String buttonConstraints = largerButtons + ? "w 10:33%, h 40px:40px:60px" + : "w 10:33%, hmin 24px"; + + // Two-row layout: 3 buttons on top, 2 on bottom + container.setLayout(new MigLayout("wrap 3, gap 2px!, insets 3px")); + + // Row 1: Clear Stack, Combat, End Step + container.add(btnClearStack, buttonConstraints); + container.add(btnCombat, buttonConstraints); + container.add(btnEndStep, buttonConstraints); + + // Row 2: End Turn, Your Turn + container.add(btnEndTurn, buttonConstraints); + container.add(btnYourTurn, buttonConstraints); + } + + @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; + } + + // Button getters + public FButton getBtnClearStack() { return btnClearStack; } + public FButton getBtnCombat() { return btnCombat; } + public FButton getBtnEndStep() { return btnEndStep; } + public FButton getBtnEndTurn() { return btnEndTurn; } + public FButton getBtnYourTurn() { return btnYourTurn; } +} diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java b/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java index 3e7e7aa1781..3fc7d4d0265 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java @@ -57,6 +57,8 @@ public class FButton extends SkinnedButton implements ILocalRepaint, IButton { private boolean allImagesPresent = false; private boolean toggle = false; private boolean hovered = false; + private boolean useHighlightMode = false; // Enable inverted color mode for yield buttons + private boolean highlighted = false; // When in highlight mode: true = red (active), false = blue (normal) private final AlphaComposite disabledComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.25f); private KeyAdapter klEnter; @@ -155,6 +157,20 @@ private void resetImg() { imgM = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_CENTER); imgR = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_RIGHT); } + else if (useHighlightMode) { + // Highlight mode for yield buttons: + // - highlighted=true: UP images (red/orange) for active yield + // - highlighted=false: FOCUS images (blue) for normal state + if (highlighted) { + imgL = FSkin.getIcon(FSkinProp.IMG_BTN_UP_LEFT); + imgM = FSkin.getIcon(FSkinProp.IMG_BTN_UP_CENTER); + imgR = FSkin.getIcon(FSkinProp.IMG_BTN_UP_RIGHT); + } else { + imgL = FSkin.getIcon(FSkinProp.IMG_BTN_FOCUS_LEFT); + imgM = FSkin.getIcon(FSkinProp.IMG_BTN_FOCUS_CENTER); + imgR = FSkin.getIcon(FSkinProp.IMG_BTN_FOCUS_RIGHT); + } + } else if (isFocusOwner()) { imgL = FSkin.getIcon(FSkinProp.IMG_BTN_FOCUS_LEFT); imgM = FSkin.getIcon(FSkinProp.IMG_BTN_FOCUS_CENTER); @@ -209,6 +225,47 @@ else if (isEnabled()) { repaintSelf(); } + /** + * Enable highlight mode for this button. + * In highlight mode, button colors are inverted: + * - Normal state uses FOCUS images (blue) + * - Highlighted state uses UP images (red/orange) + * Used for yield buttons. + * @param b0 true to enable highlight mode + */ + public void setUseHighlightMode(final boolean b0) { + this.useHighlightMode = b0; + if (isEnabled() && !isToggled()) { + resetImg(); + repaintSelf(); + } + } + + /** + * Check if button is in highlighted state. + * Only meaningful when useHighlightMode is true. + * @return boolean + */ + public boolean isHighlighted() { + return highlighted; + } + + /** + * Set highlighted state for the button. + * Requires useHighlightMode to be enabled first. + * When highlighted=false: uses FOCUS images (blue) + * When highlighted=true: uses UP images (red/orange) + * This is used for yield buttons to show which yield is active. + * @param b0 true to highlight (red), false for normal (blue) + */ + public void setHighlighted(final boolean b0) { + this.highlighted = b0; + if (isEnabled() && !isToggled()) { + resetImg(); + repaintSelf(); + } + } + public int getAutoSizeWidth() { int width = 0; if (this.getText() != null && !this.getText().isEmpty()) { diff --git a/forge-gui/res/defaults/match.xml b/forge-gui/res/defaults/match.xml index 839733ea2a8..3f296765da4 100644 --- a/forge-gui/res/defaults/match.xml +++ b/forge-gui/res/defaults/match.xml @@ -5,6 +5,7 @@ REPORT_COMBAT REPORT_LOG REPORT_DEPENDENCIES + REPORT_YIELD REPORT_MESSAGE diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index b068733e0ce..39f43f4ad6b 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1536,6 +1536,7 @@ lblYieldUntilEndStep=Yield Until End Step lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? lblNoManaAvailableYieldPrompt=You have no mana available. Yield until your turn? lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? +lblYieldSuggestionDeclineHint=(Declining disables this prompt until next turn) lblYieldSuggestion=Yield Suggestion lblAccept=Accept lblDecline=Decline @@ -1547,7 +1548,7 @@ lblInterruptOnBlockers=When you can declare blockers lblInterruptOnTargeting=When targeted by spell or ability lblInterruptOnOpponentSpell=When opponent casts a spell lblInterruptOnCombat=At beginning of combat -lblInterruptOnReveal=When cards are revealed +lblInterruptOnReveal=When cards revealed or choices made lblSuggestStackYield=When can't respond to stack lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available 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 19576f72a5b..fe315d0aa48 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -425,6 +425,10 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? + // Smart suggestion decline tracking (reset each turn) + private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); + private final Map declinedSuggestionsTurn = Maps.newHashMap(); + /** * Automatically pass priority until reaching the Cleanup phase of the * current turn. @@ -543,7 +547,8 @@ public final void updateAutoPassPrompt() { // Extended yield mode methods (experimental feature) @Override - public final void setYieldMode(final PlayerView player, final YieldMode mode) { + public final void setYieldMode(PlayerView player, final YieldMode mode) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance if (!isYieldExperimentalEnabled()) { // Fall back to legacy behavior for UNTIL_END_OF_TURN if (mode == YieldMode.UNTIL_END_OF_TURN) { @@ -592,7 +597,8 @@ public final void setYieldMode(final PlayerView player, final YieldMode mode) { } @Override - public final void clearYieldMode(final PlayerView player) { + public final void clearYieldMode(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance playerYieldMode.remove(player); yieldStartTurn.remove(player); yieldCombatStartTurn.remove(player); @@ -608,7 +614,8 @@ public final void clearYieldMode(final PlayerView player) { } @Override - public final YieldMode getYieldMode(final PlayerView player) { + public final YieldMode getYieldMode(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance // Check legacy auto-pass first if (autoPassUntilEndOfTurn.contains(player)) { return YieldMode.UNTIL_END_OF_TURN; @@ -618,7 +625,8 @@ public final YieldMode getYieldMode(final PlayerView player) { } @Override - public final boolean shouldAutoYieldForPlayer(final PlayerView player) { + public final boolean shouldAutoYieldForPlayer(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance // Check legacy system first if (autoPassUntilEndOfTurn.contains(player)) { return true; @@ -774,21 +782,24 @@ private boolean shouldInterruptYield(final PlayerView player) { forge.game.Game game = getGameView().getGame(); forge.game.player.Player p = game.getPlayer(player); + if (p == null) { + return false; // Can't determine player, don't interrupt + } ForgePreferences prefs = FModel.getPreferences(); forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { - // Only interrupt if there are creatures attacking THIS player specifically + // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && - game.getCombat() != null && !game.getCombat().getAttackersOf(p).isEmpty()) { + game.getCombat() != null && isBeingAttacked(game, p)) { return true; } } if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { - // Only interrupt if there are creatures attacking THIS player specifically + // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && - game.getCombat() != null && !game.getCombat().getAttackersOf(p).isEmpty()) { + game.getCombat() != null && isBeingAttacked(game, p)) { return true; } } @@ -824,6 +835,30 @@ private boolean shouldInterruptYield(final PlayerView player) { return false; } + private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p) { + forge.game.combat.Combat combat = game.getCombat(); + if (combat == null) { + return false; + } + + // Check if player is being attacked directly + if (!combat.getAttackersOf(p).isEmpty()) { + return true; + } + + // Check if any planeswalkers or battles controlled by the player are being attacked + for (forge.game.GameEntity defender : combat.getDefenders()) { + if (defender instanceof forge.game.card.Card) { + forge.game.card.Card card = (forge.game.card.Card) defender; + if (card.getController().equals(p) && !combat.getAttackersOf(defender).isEmpty()) { + return true; + } + } + } + + return false; + } + private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { PlayerView pv = p.getView(); @@ -850,6 +885,39 @@ public int getPlayerCount() { : 0; } + @Override + public void declineSuggestion(PlayerView player, String suggestionType) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + if (getGameView() == null || getGameView().getGame() == null) return; + + int currentTurn = getGameView().getGame().getPhaseHandler().getTurn(); + Integer storedTurn = declinedSuggestionsTurn.get(player); + + // Reset if turn changed + if (storedTurn == null || storedTurn != currentTurn) { + declinedSuggestionsThisTurn.put(player, Sets.newHashSet()); + declinedSuggestionsTurn.put(player, currentTurn); + } + + declinedSuggestionsThisTurn.get(player).add(suggestionType); + } + + @Override + public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + if (getGameView() == null || getGameView().getGame() == null) return false; + + int currentTurn = getGameView().getGame().getPhaseHandler().getTurn(); + Integer storedTurn = declinedSuggestionsTurn.get(player); + + if (storedTurn == null || storedTurn != currentTurn) { + return false; // Turn changed, reset + } + + Set declined = declinedSuggestionsThisTurn.get(player); + return declined != null && declined.contains(suggestionType); + } + // End auto-yield/input code // Abilities to auto-yield to 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 5701eb8dfa0..f9731461b54 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 @@ -52,6 +52,7 @@ public class InputPassPriority extends InputSyncronizedBase { // Pending yield suggestion state for prompt integration private YieldMode pendingSuggestion = null; + private String pendingSuggestionType = null; // "STACK_YIELD", "NO_MANA", "NO_ACTIONS" private String pendingSuggestionMessage = null; public InputPassPriority(final PlayerControllerHuman controller) { @@ -68,22 +69,31 @@ public final void showMessage() { Localizer loc = Localizer.getInstance(); // Suggestion 1: Stack items but can't respond - if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_STACK_YIELD) && shouldShowStackYieldPrompt()) { + if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_STACK_YIELD) + && shouldShowStackYieldPrompt() + && !getController().getGui().isSuggestionDeclined(getOwner(), "STACK_YIELD")) { pendingSuggestion = YieldMode.UNTIL_STACK_CLEARS; + pendingSuggestionType = "STACK_YIELD"; pendingSuggestionMessage = loc.getMessage("lblCannotRespondToStackYieldPrompt"); showYieldSuggestionPrompt(); return; } // Suggestion 2: Has cards but no mana - else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_MANA) && shouldShowNoManaPrompt()) { + else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_MANA) + && shouldShowNoManaPrompt() + && !getController().getGui().isSuggestionDeclined(getOwner(), "NO_MANA")) { pendingSuggestion = getDefaultYieldMode(); + pendingSuggestionType = "NO_MANA"; pendingSuggestionMessage = loc.getMessage("lblNoManaAvailableYieldPrompt"); showYieldSuggestionPrompt(); return; } // Suggestion 3: No available actions (empty hand, no abilities) - else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) && shouldShowNoActionsPrompt()) { + else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) + && shouldShowNoActionsPrompt() + && !getController().getGui().isSuggestionDeclined(getOwner(), "NO_ACTIONS")) { pendingSuggestion = getDefaultYieldMode(); + pendingSuggestionType = "NO_ACTIONS"; pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); showYieldSuggestionPrompt(); return; @@ -95,7 +105,8 @@ else if (prefs.getPrefBoolean(FPref.YIELD_SUGGEST_NO_ACTIONS) && shouldShowNoAct private void showYieldSuggestionPrompt() { Localizer loc = Localizer.getInstance(); - showMessage(pendingSuggestionMessage); + String fullMessage = pendingSuggestionMessage + "\n" + loc.getMessage("lblYieldSuggestionDeclineHint"); + showMessage(fullMessage); chosenSa = null; getController().getGui().updateButtons(getOwner(), loc.getMessage("lblAccept"), @@ -106,6 +117,7 @@ private void showYieldSuggestionPrompt() { private void showNormalPrompt() { pendingSuggestion = null; + pendingSuggestionType = null; pendingSuggestionMessage = null; showMessage(getTurnPhasePriorityMessage(getController().getGame())); @@ -129,10 +141,22 @@ private boolean isAlreadyYielding() { /** {@inheritDoc} */ @Override protected final void onOk() { - // If accepting a yield suggestion + // If accepting a yield suggestion (but not if a yield was already set externally) if (pendingSuggestion != null) { + // Check if a yield mode was already set (e.g., by clicking a yield button) + YieldMode currentMode = getController().getGui().getYieldMode(getOwner()); + if (currentMode != null && currentMode != YieldMode.NONE) { + // A yield mode is already active - clear suggestion and pass through + pendingSuggestion = null; + pendingSuggestionType = null; + pendingSuggestionMessage = null; + stop(); + return; + } + YieldMode mode = pendingSuggestion; pendingSuggestion = null; + pendingSuggestionType = null; pendingSuggestionMessage = null; getController().getGui().setYieldMode(getOwner(), mode); stop(); @@ -148,8 +172,15 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { - // If declining a yield suggestion, show normal prompt + // If declining a yield suggestion, track the decline and show normal prompt if (pendingSuggestion != null) { + // Track that this suggestion was declined for this turn + if (pendingSuggestionType != null) { + getController().getGui().declineSuggestion(getOwner(), pendingSuggestionType); + } + pendingSuggestion = null; + pendingSuggestionType = null; + pendingSuggestionMessage = null; showNormalPrompt(); return; } diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 61bda0b6f4d..885e0b33270 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -273,6 +273,11 @@ public interface IGuiGame { int getPlayerCount(); + // Smart suggestion decline tracking + void declineSuggestion(PlayerView player, String suggestionType); + + boolean isSuggestionDeclined(PlayerView player, String suggestionType); + boolean shouldAutoYield(String key); void setShouldAutoYield(String key, boolean autoYield); 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 7b6cad04f03..b2d2521749f 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -145,6 +145,8 @@ public enum FPref implements PreferencesStore.IPref { YIELD_INTERRUPT_ON_TARGETING("true"), YIELD_INTERRUPT_ON_OPPONENT_SPELL("false"), YIELD_INTERRUPT_ON_COMBAT("false"), + YIELD_INTERRUPT_ON_REVEAL("false"), // When opponent reveals cards + YIELD_SHOW_RIGHT_CLICK_MENU("false"), // Show right-click yield menu on End Turn UI_STACK_EFFECT_NOTIFICATION_POLICY ("Never"), UI_LAND_PLAYED_NOTIFICATION_POLICY ("Never"), diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index f2d0077bba2..b07e6370119 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -955,6 +955,21 @@ 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) { + // Skip reveal dialog during active yield if "Interrupt on Reveal" is disabled + forge.gamemodes.match.YieldMode yieldMode = getGui().getYieldMode(getLocalPlayerView()); + if (yieldMode != null && yieldMode != forge.gamemodes.match.YieldMode.NONE) { + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_INTERRUPT_ON_REVEAL)) { + // Still show the cards temporarily but skip the dialog that requires user input + if (!cards.isEmpty()) { + tempShowCards(cards); + TrackableCollection collection = CardView.getCollection(cards); + getGui().updateRevealedCards(collection); + endTempShowCards(); + } + return; + } + } + if (StringUtils.isBlank(message)) { message = localizer.getMessage("lblLookCardInPlayerZone", "{player's}", zone.getTranslatedName().toLowerCase()); } else if (addSuffix) { @@ -1750,6 +1765,16 @@ public void notifyOfValue(final SpellAbility sa, final GameObject realtedTarget, if (sa != null && sa.isManaAbility()) { getGame().getGameLog().add(GameLogEntryType.LAND, message); } else { + // Skip notification dialog during active yield if "Interrupt on Reveal/Choices" is disabled + forge.gamemodes.match.YieldMode yieldMode = getGui().getYieldMode(getLocalPlayerView()); + if (yieldMode != null && yieldMode != forge.gamemodes.match.YieldMode.NONE) { + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_INTERRUPT_ON_REVEAL)) { + // Log the message but don't show a dialog + getGame().getGameLog().add(GameLogEntryType.INFORMATION, message); + return; + } + } + if (sa != null && sa.getHostCard() != null && GuiBase.getInterface().isLibgdxPort()) { CardView cardView; IPaperCard iPaperCard = sa.getHostCard().getPaperCard(); From 43b389824b80e8b090c41c85c5af4bd98c956a1d Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Fri, 30 Jan 2026 07:28:20 +1030 Subject: [PATCH 11/33] Add mass removal interrupt option for yield system Adds a new interrupt condition that triggers when an opponent casts a mass removal spell (board wipes, exile all, etc.) that could affect the player's permanents. Detects DestroyAll, ChangeZoneAll (with exile/graveyard destination), DamageAll, and SacrificeAll effects. Only interrupts if the player has permanents matching the spell's ValidCards filter - empty board means no interrupt. Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 49 +++++-- .../forge/screens/match/menus/GameMenu.java | 1 + forge-gui/res/languages/en-US.properties | 1 + .../gamemodes/match/AbstractGuiGame.java | 126 ++++++++++++++++++ .../properties/ForgePreferences.java | 1 + 5 files changed, 167 insertions(+), 11 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c78051dcdd7..bbe54f544f3 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -71,6 +71,7 @@ Yield modes can be configured to automatically cancel when: - An opponent casts any spell (default: OFF) - Combat begins (default: OFF) - Cards are revealed or choices are made (default: OFF) - when disabled, reveal dialogs and opponent choice notifications are auto-dismissed during yield +- Mass removal spell cast by opponent (default: ON) - detects DestroyAll, ChangeZoneAll (exile/graveyard), DamageAll, SacrificeAll effects; only interrupts if you have permanents matching the spell's filter **Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. @@ -91,7 +92,14 @@ Once enabled: ### Architecture -All changes are in the **GUI layer only** - no modifications to core game logic or rules engine: +All changes are in the **GUI layer only** - no modifications to core game logic, rules engine, or network protocol: + +**Key Point: Network Independence** +- The yield system operates entirely at the GUI/client layer +- It automates *when* to pass priority, not *how* priority is passed +- Standard priority pass messages are sent through the existing network protocol +- Each client manages its own yield state independently - no yield state is synchronized between clients +- Compatible with existing network play without any protocol changes ``` forge-gui/ (shared GUI code) @@ -102,8 +110,8 @@ forge-gui/ (shared GUI code) ├── IGameController.java # Controller interface ├── PlayerControllerHuman.java # Controller implementation ├── ForgePreferences.java # New preferences -├── NetGameController.java # Network protocol -├── ProtocolMethod.java # Protocol enum +├── NetGameController.java # Controller interface implementation (no protocol changes) +├── ProtocolMethod.java # Interface method declarations └── en-US.properties # Localization forge-gui-desktop/ (desktop-specific) @@ -121,9 +129,9 @@ forge-gui-desktop/ (desktop-specific) ### Key Design Decisions 1. **Feature-gated**: Master toggle prevents accidental activation; default OFF -2. **GUI layer only**: No changes to `forge-game` rules engine -3. **Backward compatible**: Existing Ctrl+E behavior unchanged -4. **Network-aware**: Protocol methods added for multiplayer sync +2. **GUI layer only**: No changes to `forge-game` rules engine or network protocol +3. **Network independent**: Yield state is client-local; no synchronization needed +4. **Backward compatible**: Existing Ctrl+E behavior unchanged 5. **Individual toggles**: Each suggestion/interrupt can be configured separately ### End Turn Button Behavior @@ -188,8 +196,8 @@ The `shouldAutoYieldForPlayer()` method checks: - `IGameController.java` - Controller interface - `PlayerControllerHuman.java` - Controller implementation, reveal skip during yield - `ForgePreferences.java` - 13 new preferences -- `NetGameController.java` - Network protocol implementation -- `ProtocolMethod.java` - Protocol enum values +- `NetGameController.java` - Controller interface implementation (no network protocol changes) +- `ProtocolMethod.java` - Interface method declarations - `en-US.properties` - 30+ localization strings **forge-gui-desktop (7 files):** @@ -222,6 +230,7 @@ YIELD_INTERRUPT_ON_TARGETING("true") YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") YIELD_INTERRUPT_ON_COMBAT("false") YIELD_INTERRUPT_ON_REVEAL("false") // Also covers opponent choices +YIELD_INTERRUPT_ON_MASS_REMOVAL("true") // Board wipes, exile all, etc. // Display options YIELD_SHOW_RIGHT_CLICK_MENU("false") // Right-click menu on End Turn button @@ -300,23 +309,41 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 - [ ] "Clear Stack" button disabled when stack is empty #### Network Play -- [ ] Yield modes sync correctly between clients -- [ ] No desync when one player uses extended yields +- [ ] Yield modes work correctly in network games (each client manages its own yield state) +- [ ] No desync when one player uses extended yields (yield is client-local) ## Risk Assessment ### Low Risk - Feature-gated with default OFF - No changes to game rules or logic +- No changes to network protocol or synchronization +- GUI layer changes only - game rules unaffected - Existing behavior unchanged when feature disabled ### Considerations - **Mobile**: Changes are desktop-only (VPrompt, GameMenu, KeyboardShortcuts) -- **Network**: Protocol changes require matching client versions - **Preferences**: New preferences added; old preference files compatible ## Changelog +### 2026-01-30 - Mass Removal Interrupt Option + +**New Feature:** +1. **Mass removal spell interrupt** - New interrupt option that triggers when an opponent casts a mass removal spell that could affect your permanents (default: ON). Detects: + - `DestroyAll` - Wrath of God, Day of Judgment, Damnation + - `ChangeZoneAll` (exile/graveyard) - Farewell, Merciless Eviction + - `DamageAll` - Blasphemous Act, Chain Reaction + - `SacrificeAll` - All Is Dust, Bane of Progress + + The interrupt only triggers if you have permanents matching the spell's filter - empty board = no interrupt. + +**Files Changed:** +- `ForgePreferences.java` - Added `YIELD_INTERRUPT_ON_MASS_REMOVAL` preference +- `en-US.properties` - Added localization string +- `GameMenu.java` - Added menu checkbox +- `AbstractGuiGame.java` - Added detection logic (`hasMassRemovalOnStack`, `isMassRemovalSpell`, `checkSingleAbilityForMassRemoval`, `playerHasMatchingPermanents`) + ### 2026-01-29 - Auto-Suppress Suggestions & Bug Fixes **New Features:** 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 ad84d05ef4b..a881f629090 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 @@ -226,6 +226,7 @@ private JMenu getYieldOptionsMenu() { interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnCombat"), FPref.YIELD_INTERRUPT_ON_COMBAT)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)); yieldMenu.add(interruptMenu); // Sub-menu 2: Automatic Suggestions diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 39f43f4ad6b..137ccf6f955 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1549,6 +1549,7 @@ lblInterruptOnTargeting=When targeted by spell or ability lblInterruptOnOpponentSpell=When opponent casts a spell lblInterruptOnCombat=At beginning of combat lblInterruptOnReveal=When cards revealed or choices made +lblInterruptOnMassRemoval=When mass removal spell cast lblSuggestStackYield=When can't respond to stack lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available 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 fe315d0aa48..a28620a5b0f 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -832,6 +832,12 @@ private boolean shouldInterruptYield(final PlayerView player) { } } + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { + if (hasMassRemovalOnStack(game, p)) { + return true; + } + } + return false; } @@ -874,6 +880,126 @@ private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView return false; } + /** + * Check if there's a mass removal spell on the stack that could affect the player's permanents. + * Only interrupts if the spell was cast by an opponent AND the player has permanents that match. + */ + private boolean hasMassRemovalOnStack(forge.game.Game game, forge.game.player.Player p) { + if (game.getStack().isEmpty()) { + return false; + } + + for (forge.game.spellability.SpellAbilityStackInstance si : game.getStack()) { + forge.game.spellability.SpellAbility sa = si.getSpellAbility(); + if (sa == null) continue; + + // Only interrupt for opponent's spells + if (sa.getActivatingPlayer() == null || sa.getActivatingPlayer().equals(p)) { + continue; + } + + // Check if this is a mass removal spell type + if (isMassRemovalSpell(sa, game, p)) { + return true; + } + } + return false; + } + + /** + * Determine if a spell ability is a mass removal effect that could affect the player. + */ + private boolean isMassRemovalSpell(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { + forge.game.ability.ApiType api = sa.getApi(); + if (api == null) { + return false; + } + + // Check the main ability and all sub-abilities (for modal spells like Farewell) + forge.game.spellability.SpellAbility current = sa; + while (current != null) { + if (checkSingleAbilityForMassRemoval(current, game, p)) { + return true; + } + current = current.getSubAbility(); + } + + return false; + } + + /** + * Check if a single ability (not including sub-abilities) is mass removal affecting the player. + */ + private boolean checkSingleAbilityForMassRemoval(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { + forge.game.ability.ApiType api = sa.getApi(); + if (api == null) { + return false; + } + + String apiName = api.name(); + + // DestroyAll - Wrath of God, Day of Judgment, Damnation + if ("DestroyAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + // ChangeZoneAll with Destination=Exile or Graveyard - Farewell, Merciless Eviction + if ("ChangeZoneAll".equals(apiName)) { + String destination = sa.getParam("Destination"); + if ("Exile".equals(destination) || "Graveyard".equals(destination)) { + // Check Origin - only care about Battlefield + String origin = sa.getParam("Origin"); + if (origin != null && origin.contains("Battlefield")) { + return playerHasMatchingPermanents(sa, game, p, "ChangeType"); + } + } + } + + // DamageAll - Blasphemous Act, Chain Reaction + if ("DamageAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + // SacrificeAll - All Is Dust, Bane of Progress + if ("SacrificeAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + return false; + } + + /** + * Check if the player has any permanents that match the spell's filter parameter. + */ + private boolean playerHasMatchingPermanents(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p, String filterParam) { + String validFilter = sa.getParam(filterParam); + + // Get all permanents controlled by the player + forge.game.card.CardCollectionView playerPermanents = p.getCardsIn(forge.game.zone.ZoneType.Battlefield); + if (playerPermanents.isEmpty()) { + return false; // No permanents = no reason to interrupt + } + + // If no filter specified, assume it affects all permanents + if (validFilter == null || validFilter.isEmpty()) { + return true; + } + + // Check if any of the player's permanents match the filter + for (forge.game.card.Card card : playerPermanents) { + try { + if (card.isValid(validFilter.split(","), sa.getActivatingPlayer(), sa.getHostCard(), sa)) { + return true; + } + } catch (Exception e) { + // If validation fails, be conservative and assume it might affect us + return true; + } + } + + return false; + } + private boolean isYieldExperimentalEnabled() { return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); } 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 b2d2521749f..47a9339abe9 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -146,6 +146,7 @@ public enum FPref implements PreferencesStore.IPref { YIELD_INTERRUPT_ON_OPPONENT_SPELL("false"), YIELD_INTERRUPT_ON_COMBAT("false"), YIELD_INTERRUPT_ON_REVEAL("false"), // When opponent reveals cards + YIELD_INTERRUPT_ON_MASS_REMOVAL("true"), // When mass removal spell cast YIELD_SHOW_RIGHT_CLICK_MENU("false"), // Show right-click yield menu on End Turn UI_STACK_EFFECT_NOTIFICATION_POLICY ("Never"), From 59086a68e8a58adf87c5d3a18df5b3e6e911636f Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Fri, 30 Jan 2026 08:04:59 +1030 Subject: [PATCH 12/33] Add Yield Until Next Phase mode with dynamic hotkey display New Features: - UNTIL_NEXT_PHASE yield mode that clears on any phase transition - Dynamic hotkey display in tooltips and prompts based on user preferences - Button layout reordered: Next Phase, Combat, End Step / End Turn, Your Turn, Clear Stack Hotkey defaults (F1-F6): - F1: Next Phase (new) - F2: Combat - F3: End Step - F4: End Turn - F5: Your Turn - F6: Clear Stack Files changed: - YieldMode.java: Added UNTIL_NEXT_PHASE enum - YieldController.java: Phase tracking, dynamic cancel key display - VYield.java: New button, dynamic tooltip updates - CYield.java: Action listener and highlight logic - KeyboardShortcuts.java: New shortcut action - ForgePreferences.java: New preference, reordered F-keys - en-US.properties: Localization with {0} placeholders for hotkeys Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + DOCUMENTATION.md | 52 +- .../java/forge/control/KeyboardShortcuts.java | 77 +- .../screens/match/controllers/CYield.java | 54 +- .../forge/screens/match/menus/GameMenu.java | 2 +- .../forge/screens/match/views/VPrompt.java | 42 +- .../forge/screens/match/views/VYield.java | 71 +- forge-gui/res/languages/en-US.properties | 27 +- .../gamemodes/match/AbstractGuiGame.java | 566 +------------- .../gamemodes/match/YieldController.java | 739 ++++++++++++++++++ .../java/forge/gamemodes/match/YieldMode.java | 1 + .../forge/gamemodes/net/ProtocolMethod.java | 2 - .../net/client/NetGameController.java | 25 - .../forge/interfaces/IGameController.java | 11 - .../properties/ForgePreferences.java | 10 +- .../forge/player/PlayerControllerHuman.java | 20 - 16 files changed, 1034 insertions(+), 669 deletions(-) create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldController.java diff --git a/.gitignore b/.gitignore index eb48c74dba1..b50f2d44ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,7 @@ forge-gui/tools/PerSetTrackingResults # Ignore python temporaries __pycache__ *.pyc + +# Ignore Claude Code configuration (developer-specific) +CLAUDE.md +.claude/ diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bbe54f544f3..abeb09649b6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -21,6 +21,7 @@ Extended yield options that allow players to automatically pass priority until s | Mode | Description | End Condition | Availability | |------|-------------|---------------|--------------| +| Next Phase | Auto-pass until phase changes | Any phase transition | Always | | Next Turn | Auto-pass until next turn | Turn number changes | Always | | Until Stack Clears | Auto-pass while stack has items | Stack becomes empty (including simultaneous triggers) | Always | | Until Before Combat | Auto-pass until combat begins | Next COMBAT_BEGIN phase (tracks start turn/phase) | Always | @@ -30,11 +31,12 @@ Extended yield options that allow players to automatically pass priority until s ### Access Methods 1. **Yield Options Panel**: A dockable panel with dedicated yield buttons: - - **Clear Stack** - Yield until stack clears (only enabled when stack has items) + - **Next Phase** - Yield until next phase begins - **Combat** - Yield until before combat - **End Step** - Yield until end step - **Next Turn** - Yield until next turn - **Your Turn** - Yield until your next turn (only visible in 3+ player games) + - **Clear Stack** - Yield until stack clears (only enabled when stack has items) - Buttons are blue by default, red when that yield mode is active - Panel appears as a tab alongside the Stack panel when experimental yields are enabled - Buttons are disabled during mulligan and pre-game phases @@ -42,11 +44,12 @@ Extended yield options that allow players to automatically pass priority until s 2. **Right-Click Menu**: Right-click the "End Turn" button to see yield options (configurable) 3. **Keyboard Shortcuts** (F-keys to avoid conflict with ability selection 1-9): - - `F1` - Yield until next turn - - `F2` - Yield until stack clears - - `F3` - Yield until before combat - - `F4` - Yield until end step + - `F1` - Yield until next phase + - `F2` - Yield until before combat + - `F3` - Yield until end step + - `F4` - Yield until next turn - `F5` - Yield until your next turn (3+ players) + - `F6` - Yield until stack clears - `ESC` - Cancel active yield ### Smart Yield Suggestions @@ -163,6 +166,7 @@ private final Map yieldCombatStartedAtOrAfterCombat = Maps. private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? +private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); // Track phase when next phase yield was set // Smart suggestion decline tracking (resets each turn) private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); @@ -174,6 +178,7 @@ The `shouldAutoYieldForPlayer()` method checks: 2. Current yield mode 3. Interrupt conditions 4. Mode-specific end conditions: + - `UNTIL_NEXT_PHASE`: Clears when phase changes (tracked via `yieldNextPhaseStartPhase`) - `UNTIL_STACK_CLEARS`: Clears when stack is empty AND no simultaneous stack entries - `UNTIL_END_OF_TURN`: Clears when turn number changes (tracked via `yieldStartTurn`) - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes active player; if started during own turn, waits until turn comes back around @@ -236,11 +241,12 @@ YIELD_INTERRUPT_ON_MASS_REMOVAL("true") // Board wipes, exile all, etc. YIELD_SHOW_RIGHT_CLICK_MENU("false") // Right-click menu on End Turn button // Keyboard shortcuts (F-keys) -SHORTCUT_YIELD_UNTIL_END_OF_TURN("112") // F1 -SHORTCUT_YIELD_UNTIL_STACK_CLEARS("113") // F2 -SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("114") // F3 -SHORTCUT_YIELD_UNTIL_END_STEP("115") // F4 +SHORTCUT_YIELD_UNTIL_NEXT_PHASE("112") // F1 +SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("113") // F2 +SHORTCUT_YIELD_UNTIL_END_STEP("114") // F3 +SHORTCUT_YIELD_UNTIL_END_OF_TURN("115") // F4 SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 +SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 ``` ## Testing Guide @@ -327,6 +333,34 @@ SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 ## Changelog +### 2026-01-30 - Yield Until Next Phase & Dynamic Hotkeys + +**New Feature:** +1. **Yield Until Next Phase** - New yield mode that automatically passes priority until the next phase begins. This is a simple, predictable yield that clears on any phase transition. + +2. **Dynamic Hotkey Display** - All hotkey references in button tooltips and yield prompt messages now dynamically update based on user preferences instead of showing hardcoded values. If a user changes their keyboard shortcuts, the UI will reflect the new bindings. + +**Button Layout Change:** +- Row 1: Next Phase, Combat, End Step +- Row 2: End Turn, Your Turn, Clear Stack + +**Hotkey Reorder (defaults):** +- F1: Next Phase (new) +- F2: Combat +- F3: End Step +- F4: End Turn +- F5: Your Turn +- F6: Clear Stack + +**Files Changed:** +- `YieldMode.java` - Added `UNTIL_NEXT_PHASE` enum value +- `YieldController.java` - Added `yieldNextPhaseStartPhase` tracking, setYieldMode/shouldAutoYield/clearYieldMode logic, `getCancelShortcutDisplayText()` method +- `VYield.java` - Added btnNextPhase button, reordered layout, `updateTooltips()` method with dynamic shortcut text, `getShortcutDisplayText()` utility +- `CYield.java` - Added actNextPhase action listener, yieldUntilNextPhase method, highlight logic +- `KeyboardShortcuts.java` - Added actYieldUntilNextPhase action, reordered shortcut list +- `ForgePreferences.java` - Added SHORTCUT_YIELD_UNTIL_NEXT_PHASE, reordered F-key assignments +- `en-US.properties` - Added localization strings, updated tooltips and prompts to use `{0}` placeholder for dynamic hotkeys + ### 2026-01-30 - Mass Removal Interrupt Option **New Feature:** 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 af8e9b99f72..2242dfdb55a 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -18,6 +18,7 @@ import forge.Singletons; import forge.game.spellability.StackItemView; +import forge.gamemodes.match.YieldMode; import forge.gui.framework.EDocID; import forge.gui.framework.SDisplayUtil; import forge.localinstance.properties.ForgePreferences; @@ -113,16 +114,31 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Yield until next phase (experimental). */ + final Action actYieldUntilNextPhase = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_NEXT_PHASE); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + }; + /** Yield until stack clears (experimental). */ final Action actYieldUntilStackClears = new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } - matchUI.getGameController().yieldUntilStackClears(); - // Also pass priority to actually start yielding - matchUI.getGameController().selectButtonOk(); + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } }; @@ -131,12 +147,13 @@ public void actionPerformed(final ActionEvent e) { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } if (matchUI.getPlayerCount() >= 3) { - matchUI.getGameController().yieldUntilYourNextTurn(); - // Also pass priority to actually start yielding - matchUI.getGameController().selectButtonOk(); + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } }; @@ -146,10 +163,12 @@ public void actionPerformed(final ActionEvent e) { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } - matchUI.getGameController().yieldUntilEndOfTurn(); - matchUI.getGameController().selectButtonOk(); + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } }; @@ -158,10 +177,12 @@ public void actionPerformed(final ActionEvent e) { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } - matchUI.getGameController().yieldUntilBeforeCombat(); - matchUI.getGameController().selectButtonOk(); + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } }; @@ -170,10 +191,26 @@ public void actionPerformed(final ActionEvent e) { @Override public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } - if (matchUI == null) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_STEP); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + }; + + /** Cancel current yield mode (experimental). */ + final Action actCancelYield = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null || matchUI.getCurrentPlayer() == null) { return; } if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } - matchUI.getGameController().yieldUntilEndStep(); - matchUI.getGameController().selectButtonOk(); + YieldMode currentYield = matchUI.getYieldMode(matchUI.getCurrentPlayer()); + if (currentYield != null && currentYield != YieldMode.NONE) { + matchUI.clearYieldMode(matchUI.getCurrentPlayer()); + } } }; @@ -272,11 +309,13 @@ public void actionPerformed(ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWDEV, localizer.getMessage("lblSHORTCUT_SHOWDEV"), actShowDev, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CONCEDE, localizer.getMessage("lblSHORTCUT_CONCEDE"), actConcede, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ENDTURN, localizer.getMessage("lblSHORTCUT_ENDTURN"), actEndTurn, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_END_OF_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_END_OF_TURN"), actYieldUntilEndOfTurn, am, im)); - list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS"), actYieldUntilStackClears, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_NEXT_PHASE, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_NEXT_PHASE"), actYieldUntilNextPhase, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_BEFORE_COMBAT"), actYieldUntilBeforeCombat, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_END_STEP, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_END_STEP"), actYieldUntilEndStep, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_END_OF_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_END_OF_TURN"), actYieldUntilEndOfTurn, am, im)); list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN"), actYieldUntilYourNextTurn, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS, localizer.getMessage("lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS"), actYieldUntilStackClears, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_CANCEL, localizer.getMessage("lblSHORTCUT_YIELD_CANCEL"), actCancelYield, am, im)); list.add(new Shortcut(FPref.SHORTCUT_ALPHASTRIKE, localizer.getMessage("lblSHORTCUT_ALPHASTRIKE"), actAllAttack, am, im)); 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)); 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 index e3c168e4c51..8fcf209e94d 100644 --- 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 @@ -45,6 +45,7 @@ public class CYield implements ICDoc { private boolean isMultiplayer = false; // Yield button action listeners + private final ActionListener actNextPhase = evt -> yieldUntilNextPhase(); private final ActionListener actClearStack = evt -> yieldUntilStackClears(); private final ActionListener actCombat = evt -> yieldUntilCombat(); private final ActionListener actEndStep = evt -> yieldUntilEndStep(); @@ -74,6 +75,7 @@ public void initialize() { isMultiplayer = matchUI.getPlayerCount() >= 3; // Initialize button action listeners + initButton(view.getBtnNextPhase(), actNextPhase); initButton(view.getBtnClearStack(), actClearStack); initButton(view.getBtnCombat(), actCombat); initButton(view.getBtnEndStep(), actEndStep); @@ -94,38 +96,58 @@ public void update() { updateYieldButtons(); } - // Yield action methods + // Yield action methods - set yield mode directly on GUI, then pass priority + private void yieldUntilNextPhase() { + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_NEXT_PHASE); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + } + private void yieldUntilStackClears() { - if (matchUI != null && matchUI.getGameController() != null) { - matchUI.getGameController().yieldUntilStackClears(); - matchUI.getGameController().selectButtonOk(); + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } private void yieldUntilCombat() { - if (matchUI != null && matchUI.getGameController() != null) { - matchUI.getGameController().yieldUntilBeforeCombat(); - matchUI.getGameController().selectButtonOk(); + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } private void yieldUntilEndStep() { - if (matchUI != null && matchUI.getGameController() != null) { - matchUI.getGameController().yieldUntilEndStep(); - matchUI.getGameController().selectButtonOk(); + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_STEP); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } private void yieldUntilEndTurn() { - if (matchUI != null && matchUI.getGameController() != null) { - matchUI.getGameController().passPriorityUntilEndOfTurn(); + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } private void yieldUntilYourTurn() { - if (matchUI != null && matchUI.getGameController() != null) { - matchUI.getGameController().yieldUntilYourNextTurn(); - matchUI.getGameController().selectButtonOk(); + if (matchUI != null && matchUI.getCurrentPlayer() != null) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } } } @@ -144,6 +166,7 @@ public void updateYieldButtons() { boolean canYield = yieldEnabled && canYieldNow(); // Enable/disable all yield buttons based on whether we can yield + view.getBtnNextPhase().setEnabled(canYield); view.getBtnCombat().setEnabled(canYield); view.getBtnEndStep().setEnabled(canYield); view.getBtnEndTurn().setEnabled(canYield); @@ -176,6 +199,7 @@ private void updateActiveYieldHighlight() { // Set highlight state based on active yield mode // Highlighted = red (active), not highlighted = blue (normal) + view.getBtnNextPhase().setHighlighted(currentMode == YieldMode.UNTIL_NEXT_PHASE); view.getBtnClearStack().setHighlighted(currentMode == YieldMode.UNTIL_STACK_CLEARS); view.getBtnCombat().setHighlighted(currentMode == YieldMode.UNTIL_BEFORE_COMBAT); view.getBtnEndStep().setHighlighted(currentMode == YieldMode.UNTIL_END_STEP); 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 a881f629090..e680c89c37b 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 @@ -223,10 +223,10 @@ private JMenu getYieldOptionsMenu() { interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnBlockers"), FPref.YIELD_INTERRUPT_ON_BLOCKERS)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING)); + interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnCombat"), FPref.YIELD_INTERRUPT_ON_COMBAT)); interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL)); - interruptMenu.add(createYieldCheckbox(localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)); yieldMenu.add(interruptMenu); // Sub-menu 2: Automatic Suggestions 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 3ad90431726..5d0c88ee987 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 @@ -249,10 +249,11 @@ private void showYieldOptionsMenu(MouseEvent e) { // Until Stack Clears JMenuItem stackItem = new JMenuItem(loc.getMessage("lblYieldUntilStackClears")); stackItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().yieldUntilStackClears(); - // Also pass priority to actually start yielding - controller.getMatchUI().getGameController().selectButtonOk(); + if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { + controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().selectButtonOk(); + } } }); menu.add(stackItem); @@ -260,9 +261,11 @@ private void showYieldOptionsMenu(MouseEvent e) { // Until End of Turn JMenuItem turnItem = new JMenuItem(loc.getMessage("lblYieldUntilEndOfTurn")); turnItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().yieldUntilEndOfTurn(); - controller.getMatchUI().getGameController().selectButtonOk(); + if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { + controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().selectButtonOk(); + } } }); menu.add(turnItem); @@ -270,9 +273,11 @@ private void showYieldOptionsMenu(MouseEvent e) { // Until Combat JMenuItem combatItem = new JMenuItem(loc.getMessage("lblYieldUntilBeforeCombat")); combatItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().yieldUntilBeforeCombat(); - controller.getMatchUI().getGameController().selectButtonOk(); + if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { + controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().selectButtonOk(); + } } }); menu.add(combatItem); @@ -280,9 +285,11 @@ private void showYieldOptionsMenu(MouseEvent e) { // Until End Step JMenuItem endStepItem = new JMenuItem(loc.getMessage("lblYieldUntilEndStep")); endStepItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().yieldUntilEndStep(); - controller.getMatchUI().getGameController().selectButtonOk(); + if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { + controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_END_STEP); + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().selectButtonOk(); + } } }); menu.add(endStepItem); @@ -291,10 +298,11 @@ private void showYieldOptionsMenu(MouseEvent e) { if (controller.getMatchUI() != null && controller.getMatchUI().getPlayerCount() >= 3) { JMenuItem yourNextTurnItem = new JMenuItem(loc.getMessage("lblYieldUntilYourNextTurn")); yourNextTurnItem.addActionListener(evt -> { - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().yieldUntilYourNextTurn(); - // Also pass priority to actually start yielding - controller.getMatchUI().getGameController().selectButtonOk(); + if (controller.getMatchUI().getCurrentPlayer() != null) { + controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); + if (controller.getMatchUI().getGameController() != null) { + controller.getMatchUI().getGameController().selectButtonOk(); + } } }); menu.add(yourNextTurnItem); 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 index 9e91c0f4f9c..6bf72a9b343 100644 --- 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 @@ -17,12 +17,20 @@ */ package forge.screens.match.views; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + import javax.swing.JPanel; +import org.apache.commons.lang3.StringUtils; + 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; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.screens.match.controllers.CYield; @@ -44,6 +52,7 @@ public class VYield implements IVDoc { private final DragTab tab = new DragTab(localizer.getMessage("lblYieldOptions")); // Yield control buttons + private final FButton btnNextPhase = new FButton(localizer.getMessage("lblYieldBtnNextPhase")); private final FButton btnClearStack = new FButton(localizer.getMessage("lblYieldBtnClearStack")); private final FButton btnCombat = new FButton(localizer.getMessage("lblYieldBtnCombat")); private final FButton btnEndStep = new FButton(localizer.getMessage("lblYieldBtnEndStep")); @@ -57,6 +66,7 @@ public VYield(final CYield controller) { // Use smaller font to fit button text java.awt.Font smallFont = FSkin.getBoldFont(11).getBaseFont(); + btnNextPhase.setFont(smallFont); btnClearStack.setFont(smallFont); btnCombat.setFont(smallFont); btnEndStep.setFont(smallFont); @@ -64,18 +74,57 @@ public VYield(final CYield controller) { btnYourTurn.setFont(smallFont); // Enable highlight mode: blue by default, red when active yield + btnNextPhase.setUseHighlightMode(true); btnClearStack.setUseHighlightMode(true); btnCombat.setUseHighlightMode(true); btnEndStep.setUseHighlightMode(true); btnEndTurn.setUseHighlightMode(true); btnYourTurn.setUseHighlightMode(true); - // Set tooltips on yield buttons - btnClearStack.setToolTipText(localizer.getMessage("lblYieldBtnClearStackTooltip")); - btnCombat.setToolTipText(localizer.getMessage("lblYieldBtnCombatTooltip")); - btnEndStep.setToolTipText(localizer.getMessage("lblYieldBtnEndStepTooltip")); - btnEndTurn.setToolTipText(localizer.getMessage("lblYieldBtnEndTurnTooltip")); - btnYourTurn.setToolTipText(localizer.getMessage("lblYieldBtnYourTurnTooltip")); + // Set tooltips on yield buttons with dynamic hotkey text + updateTooltips(); + } + + /** + * Update button tooltips with current keyboard shortcut bindings. + * Call this after keyboard shortcuts are changed. + */ + public void updateTooltips() { + ForgePreferences prefs = FModel.getPreferences(); + btnNextPhase.setToolTipText(localizer.getMessage("lblYieldBtnNextPhaseTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_NEXT_PHASE)))); + btnClearStack.setToolTipText(localizer.getMessage("lblYieldBtnClearStackTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS)))); + btnCombat.setToolTipText(localizer.getMessage("lblYieldBtnCombatTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT)))); + btnEndStep.setToolTipText(localizer.getMessage("lblYieldBtnEndStepTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_END_STEP)))); + btnEndTurn.setToolTipText(localizer.getMessage("lblYieldBtnEndTurnTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_END_OF_TURN)))); + btnYourTurn.setToolTipText(localizer.getMessage("lblYieldBtnYourTurnTooltip", + getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN)))); + } + + /** + * Convert a keyboard shortcut preference string (space-separated key codes) to display text. + * e.g., "112" becomes "F1", "17 67" becomes "Ctrl C" + */ + private String getShortcutDisplayText(String codeString) { + if (codeString == null || codeString.isEmpty()) { + return ""; + } + List codes = new ArrayList<>(Arrays.asList(codeString.trim().split(" "))); + List displayText = new ArrayList<>(); + for (String s : codes) { + if (!s.isEmpty()) { + try { + displayText.add(KeyEvent.getKeyText(Integer.parseInt(s))); + } catch (NumberFormatException e) { + displayText.add(s); + } + } + } + return StringUtils.join(displayText, '+'); } @Override @@ -87,17 +136,18 @@ public void populate() { ? "w 10:33%, h 40px:40px:60px" : "w 10:33%, hmin 24px"; - // Two-row layout: 3 buttons on top, 2 on bottom + // Two-row layout: 3 buttons on top, 3 on bottom container.setLayout(new MigLayout("wrap 3, gap 2px!, insets 3px")); - // Row 1: Clear Stack, Combat, End Step - container.add(btnClearStack, buttonConstraints); + // Row 1: Next Phase, Combat, End Step + container.add(btnNextPhase, buttonConstraints); container.add(btnCombat, buttonConstraints); container.add(btnEndStep, buttonConstraints); - // Row 2: End Turn, Your Turn + // Row 2: End Turn, Your Turn, Clear Stack container.add(btnEndTurn, buttonConstraints); container.add(btnYourTurn, buttonConstraints); + container.add(btnClearStack, buttonConstraints); } @Override @@ -126,6 +176,7 @@ public CYield getLayoutControl() { } // Button getters + public FButton getBtnNextPhase() { return btnNextPhase; } public FButton getBtnClearStack() { return btnClearStack; } public FButton getBtnCombat() { return btnCombat; } public FButton getBtnEndStep() { return btnEndStep; } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 137ccf6f955..10d212b720c 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1521,22 +1521,23 @@ lblWaitingforActions=Waiting for actions... lblCloseGameSpectator=This will close this game and you will not be able to resume watching it.\n\nClose anyway? lblCloseGame=Close Game? lblWaitingForOpponent=Waiting for opponent... -lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. +lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action ({0}). +lblYieldingUntilNextPhase=Yielding until next phase.\nYou may cancel this yield to take an action ({0}). cbYieldExperimentalOptions=Experimental: Enable expanded yield options nlYieldExperimentalOptions=Adds extended yield options: yield until stack clears, yield until your next turn, smart yield suggestions, and interrupt configuration. Access via right-click on End Turn button. Options in Game toolbar. Requires restart. -lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. -lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this yield to take an action. +lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action ({0}). +lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this yield to take an action ({0}). lblYieldUntilStackClears=Yield Until Stack Clears lblYieldUntilEndOfTurn=Yield Until End of Turn lblYieldUntilYourNextTurn=Yield Until Your Next Turn -lblYieldingUntilBeforeCombat=Yielding until combat.\nYou may cancel this yield to take an action. +lblYieldingUntilBeforeCombat=Yielding until combat.\nYou may cancel this yield to take an action ({0}). lblYieldUntilBeforeCombat=Yield Until Combat -lblYieldingUntilEndStep=Yielding until end step.\nYou may cancel this yield to take an action. +lblYieldingUntilEndStep=Yielding until end step.\nYou may cancel this yield to take an action ({0}). lblYieldUntilEndStep=Yield Until End Step lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? lblNoManaAvailableYieldPrompt=You have no mana available. Yield until your turn? lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? -lblYieldSuggestionDeclineHint=(Declining disables this prompt until next turn) +lblYieldSuggestionDeclineHint=(Declining disables this prompt until next turn.) lblYieldSuggestion=Yield Suggestion lblAccept=Accept lblDecline=Decline @@ -1555,23 +1556,27 @@ lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available lblDisplayOptions=Display Options lblShowRightClickMenu=Show Right-Click Menu +lblYieldBtnNextPhase=Next Phase lblYieldBtnClearStack=Clear Stack lblYieldBtnCombat=Combat lblYieldBtnEndStep=End Step lblYieldBtnYourTurn=Your Turn -lblYieldBtnClearStackTooltip=Pass priority until the stack is empty. -lblYieldBtnCombatTooltip=Pass priority until the combat phase begins. -lblYieldBtnEndStepTooltip=Pass priority until the end step. -lblYieldBtnYourTurnTooltip=Pass priority until YOUR next turn. +lblYieldBtnNextPhaseTooltip=Pass priority until the next phase begins ({0}). +lblYieldBtnClearStackTooltip=Pass priority until the stack is empty ({0}). +lblYieldBtnCombatTooltip=Pass priority until the combat phase begins ({0}). +lblYieldBtnEndStepTooltip=Pass priority until the end step ({0}). +lblYieldBtnYourTurnTooltip=Pass priority until YOUR next turn ({0}). lblYieldBtnEndTurn=Next Turn -lblYieldBtnEndTurnTooltip=Pass priority until next turn. +lblYieldBtnEndTurnTooltip=Pass priority until next turn ({0}). lblYield=Yield lblYieldOptions=Yield Options +lblSHORTCUT_YIELD_UNTIL_NEXT_PHASE=Yield Until Next Phase lblSHORTCUT_YIELD_UNTIL_END_OF_TURN=Yield Until End of Turn lblSHORTCUT_YIELD_UNTIL_STACK_CLEARS=Yield Until Stack Clears lblSHORTCUT_YIELD_UNTIL_BEFORE_COMBAT=Yield Until Combat lblSHORTCUT_YIELD_UNTIL_END_STEP=Yield Until End Step lblSHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN=Yield Until Your Next Turn +lblSHORTCUT_YIELD_CANCEL=Cancel 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 a28620a5b0f..87cbffdbe17 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -157,7 +157,7 @@ public void setGameController(PlayerView player, final IGameController gameContr gameControllers.put(player, originalGameControllers.get(player)); } else { gameControllers.remove(player); - autoPassUntilEndOfTurn.remove(player); + getYieldController().removeFromLegacyAutoPass(player); final PlayerView currentPlayer = getCurrentPlayer(); if (player.equals(currentPlayer)) { // set current player to a value known to be legal @@ -414,20 +414,36 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final // Auto-yield and other input-related code - private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); + // Yield controller manages all yield state and logic + private YieldController yieldController; - // Extended yield mode tracking (experimental feature) - private final Map playerYieldMode = Maps.newHashMap(); - private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set - private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set - private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? - private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set - private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? - private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? - - // Smart suggestion decline tracking (reset each turn) - private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); - private final Map declinedSuggestionsTurn = Maps.newHashMap(); + private YieldController getYieldController() { + if (yieldController == null) { + yieldController = new YieldController(new YieldController.YieldCallback() { + @Override + public void showPromptMessage(PlayerView player, String message) { + AbstractGuiGame.this.showPromptMessage(player, message); + } + @Override + public void updateButtons(PlayerView player, boolean ok, boolean cancel, boolean focusOk) { + AbstractGuiGame.this.updateButtons(player, ok, cancel, focusOk); + } + @Override + public void awaitNextInput() { + AbstractGuiGame.this.awaitNextInput(); + } + @Override + public void cancelAwaitNextInput() { + AbstractGuiGame.this.cancelAwaitNextInput(); + } + @Override + public GameView getGameView() { + return AbstractGuiGame.this.getGameView(); + } + }); + } + return yieldController; + } /** * Automatically pass priority until reaching the Cleanup phase of the @@ -435,31 +451,18 @@ public void updateButtons(final PlayerView owner, final boolean okEnabled, final */ @Override public final void autoPassUntilEndOfTurn(final PlayerView player) { - autoPassUntilEndOfTurn.add(player); + getYieldController().autoPassUntilEndOfTurn(player); updateAutoPassPrompt(); } @Override public final void autoPassCancel(final PlayerView player) { - if (!autoPassUntilEndOfTurn.remove(player)) { - return; - } - - //prevent prompt getting stuck on yielding message while actually waiting for next input opportunity - final PlayerView playerView = getCurrentPlayer(); - showPromptMessage(playerView, ""); - updateButtons(playerView, false, false, false); - awaitNextInput(); + getYieldController().autoPassCancel(player); } @Override public final boolean mayAutoPass(final PlayerView player) { - // Check legacy auto-pass first - if (autoPassUntilEndOfTurn.contains(player)) { - return true; - } - // Check experimental yield system - return shouldAutoYieldForPlayer(player); + return getYieldController().mayAutoPass(player); } private Timer awaitNextInputTimer; @@ -517,531 +520,44 @@ public final void cancelAwaitNextInput() { @Override public final void updateAutoPassPrompt() { - PlayerView player = getCurrentPlayer(); - - // Check legacy auto-pass first - if (autoPassUntilEndOfTurn.contains(player)) { - cancelAwaitNextInput(); - showPromptMessage(player, Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn")); - updateButtons(player, false, true, false); - return; - } - - // Check experimental yield modes - YieldMode mode = playerYieldMode.get(player); - if (mode != null && mode != YieldMode.NONE) { - cancelAwaitNextInput(); - Localizer loc = Localizer.getInstance(); - String message = switch (mode) { - case UNTIL_STACK_CLEARS -> loc.getMessage("lblYieldingUntilStackClears"); - case UNTIL_END_OF_TURN -> loc.getMessage("lblYieldingUntilEndOfTurn"); - case UNTIL_YOUR_NEXT_TURN -> loc.getMessage("lblYieldingUntilYourNextTurn"); - case UNTIL_BEFORE_COMBAT -> loc.getMessage("lblYieldingUntilBeforeCombat"); - case UNTIL_END_STEP -> loc.getMessage("lblYieldingUntilEndStep"); - default -> ""; - }; - showPromptMessage(player, message); - updateButtons(player, false, true, false); - } + getYieldController().updateAutoPassPrompt(getCurrentPlayer()); } // Extended yield mode methods (experimental feature) @Override public final void setYieldMode(PlayerView player, final YieldMode mode) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - if (!isYieldExperimentalEnabled()) { - // Fall back to legacy behavior for UNTIL_END_OF_TURN - if (mode == YieldMode.UNTIL_END_OF_TURN) { - autoPassUntilEndOfTurn.add(player); - updateAutoPassPrompt(); - } - return; - } - - if (mode == YieldMode.NONE) { - clearYieldMode(player); - return; - } - - playerYieldMode.put(player, mode); - // Track turn number for UNTIL_END_OF_TURN mode - if (mode == YieldMode.UNTIL_END_OF_TURN && getGameView() != null && getGameView().getGame() != null) { - yieldStartTurn.put(player, getGameView().getGame().getPhaseHandler().getTurn()); - } - // Track turn and phase state for UNTIL_BEFORE_COMBAT mode - if (mode == YieldMode.UNTIL_BEFORE_COMBAT && getGameView() != null && getGameView().getGame() != null) { - forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); - yieldCombatStartTurn.put(player, ph.getTurn()); - forge.game.phase.PhaseType phase = ph.getPhase(); - boolean atOrAfterCombat = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); - } - // Track turn and phase state for UNTIL_END_STEP mode - if (mode == YieldMode.UNTIL_END_STEP && getGameView() != null && getGameView().getGame() != null) { - forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); - yieldEndStepStartTurn.put(player, ph.getTurn()); - forge.game.phase.PhaseType phase = ph.getPhase(); - boolean atOrAfterEndStep = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); - yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); - } - // Track if UNTIL_YOUR_NEXT_TURN was started during our turn - if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN && getGameView() != null && getGameView().getGame() != null) { - forge.game.phase.PhaseHandler ph = getGameView().getGame().getPhaseHandler(); - forge.game.player.Player playerObj = getGameView().getGame().getPlayer(player); - boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); - yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); - } + getYieldController().setYieldMode(player, mode); updateAutoPassPrompt(); } @Override public final void clearYieldMode(PlayerView player) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - playerYieldMode.remove(player); - yieldStartTurn.remove(player); - yieldCombatStartTurn.remove(player); - yieldCombatStartedAtOrAfterCombat.remove(player); - yieldEndStepStartTurn.remove(player); - yieldEndStepStartedAtOrAfterEndStep.remove(player); - yieldYourTurnStartedDuringOurTurn.remove(player); - autoPassUntilEndOfTurn.remove(player); // Legacy compatibility - - showPromptMessage(player, ""); - updateButtons(player, false, false, false); - awaitNextInput(); + getYieldController().clearYieldMode(player); } @Override public final YieldMode getYieldMode(PlayerView player) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - // Check legacy auto-pass first - if (autoPassUntilEndOfTurn.contains(player)) { - return YieldMode.UNTIL_END_OF_TURN; - } - YieldMode mode = playerYieldMode.get(player); - return mode != null ? mode : YieldMode.NONE; + return getYieldController().getYieldMode(player); } @Override public final boolean shouldAutoYieldForPlayer(PlayerView player) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - // Check legacy system first - if (autoPassUntilEndOfTurn.contains(player)) { - return true; - } - - if (!isYieldExperimentalEnabled()) { - return false; - } - - YieldMode mode = playerYieldMode.get(player); - if (mode == null || mode == YieldMode.NONE) { - return false; - } - - // Check interrupt conditions - if (shouldInterruptYield(player)) { - clearYieldMode(player); - return false; - } - - if (getGameView() == null || getGameView().getGame() == null) { - return false; - } - - forge.game.Game game = getGameView().getGame(); - forge.game.phase.PhaseHandler ph = game.getPhaseHandler(); - - return switch (mode) { - case UNTIL_STACK_CLEARS -> { - boolean stackEmpty = game.getStack().isEmpty() && !game.getStack().hasSimultaneousStackEntries(); - if (stackEmpty) { - clearYieldMode(player); - yield false; - } - yield true; - } - case UNTIL_END_OF_TURN -> { - // Yield until end of the turn when yield was set - clear when turn number changes - Integer startTurn = yieldStartTurn.get(player); - int currentTurn = ph.getTurn(); - if (startTurn == null) { - // Turn wasn't tracked when yield was set - track it now - yieldStartTurn.put(player, currentTurn); - yield true; - } - if (currentTurn > startTurn) { - clearYieldMode(player); - yield false; - } - yield true; - } - case UNTIL_YOUR_NEXT_TURN -> { - // Yield until our turn starts - forge.game.player.Player playerObj = game.getPlayer(player); - boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); - Boolean startedDuringOurTurn = yieldYourTurnStartedDuringOurTurn.get(player); - - if (startedDuringOurTurn == null) { - // Tracking wasn't set - initialize it now - yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); - startedDuringOurTurn = isOurTurn; - } - - if (isOurTurn) { - // If we started during our turn, we need to wait until it's our turn AGAIN - // (i.e., we left our turn and came back) - // If we started during opponent's turn, stop when we reach our turn - if (!Boolean.TRUE.equals(startedDuringOurTurn)) { - clearYieldMode(player); - yield false; - } - } else { - // Not our turn - if we started during our turn, mark that we've left it - if (Boolean.TRUE.equals(startedDuringOurTurn)) { - // We've left our turn, now waiting for it to come back - yieldYourTurnStartedDuringOurTurn.put(player, false); - } - } - yield true; - } - case UNTIL_BEFORE_COMBAT -> { - forge.game.phase.PhaseType phase = ph.getPhase(); - Integer startTurn = yieldCombatStartTurn.get(player); - Boolean startedAtOrAfterCombat = yieldCombatStartedAtOrAfterCombat.get(player); - int currentTurn = ph.getTurn(); - - if (startTurn == null) { - // Tracking wasn't set - initialize it now - yieldCombatStartTurn.put(player, currentTurn); - boolean atOrAfterCombat = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); - startTurn = currentTurn; - startedAtOrAfterCombat = atOrAfterCombat; - } - - // Check if we should stop: we're at or past combat on a DIFFERENT turn than when we started, - // OR we're at combat on the SAME turn but we started BEFORE combat - boolean atOrAfterCombatNow = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - - if (atOrAfterCombatNow) { - boolean differentTurn = currentTurn > startTurn; - boolean sameTurnButStartedBeforeCombat = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterCombat); - - if (differentTurn || sameTurnButStartedBeforeCombat) { - clearYieldMode(player); - yield false; - } - } - yield true; - } - case UNTIL_END_STEP -> { - forge.game.phase.PhaseType phase = ph.getPhase(); - Integer startTurn = yieldEndStepStartTurn.get(player); - Boolean startedAtOrAfterEndStep = yieldEndStepStartedAtOrAfterEndStep.get(player); - int currentTurn = ph.getTurn(); - - if (startTurn == null) { - // Tracking wasn't set - initialize it now - yieldEndStepStartTurn.put(player, currentTurn); - boolean atOrAfterEndStep = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); - yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); - startTurn = currentTurn; - startedAtOrAfterEndStep = atOrAfterEndStep; - } - - // Check if we should stop: we're at or past end step on a DIFFERENT turn than when we started, - // OR we're at end step on the SAME turn but we started BEFORE end step - boolean atOrAfterEndStepNow = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); - - if (atOrAfterEndStepNow) { - boolean differentTurn = currentTurn > startTurn; - boolean sameTurnButStartedBeforeEndStep = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterEndStep); - - if (differentTurn || sameTurnButStartedBeforeEndStep) { - clearYieldMode(player); - yield false; - } - } - yield true; - } - default -> false; - }; - } - - private boolean shouldInterruptYield(final PlayerView player) { - if (getGameView() == null || getGameView().getGame() == null) { - return false; - } - - forge.game.Game game = getGameView().getGame(); - forge.game.player.Player p = game.getPlayer(player); - if (p == null) { - return false; // Can't determine player, don't interrupt - } - ForgePreferences prefs = FModel.getPreferences(); - forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { - // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles - if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && - game.getCombat() != null && isBeingAttacked(game, p)) { - return true; - } - } - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { - // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles - if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && - game.getCombat() != null && isBeingAttacked(game, p)) { - return true; - } - } - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TARGETING)) { - for (forge.game.spellability.StackItemView si : getGameView().getStack()) { - if (targetsPlayerOrPermanents(si, p)) { - return true; - } - } - } - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { - if (!game.getStack().isEmpty()) { - forge.game.spellability.SpellAbility topSa = game.getStack().peekAbility(); - // Exclude triggered abilities - if they target you, the "targeting" setting handles that - if (topSa != null && !topSa.isTrigger() && !topSa.getActivatingPlayer().equals(p)) { - return true; - } - } - } - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { - if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { - YieldMode mode = playerYieldMode.get(player); - // Don't interrupt UNTIL_END_OF_TURN on our own turn - if (!(mode == YieldMode.UNTIL_END_OF_TURN && game.getPhaseHandler().getPlayerTurn().equals(p))) { - return true; - } - } - } - - if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { - if (hasMassRemovalOnStack(game, p)) { - return true; - } - } - - return false; - } - - private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p) { - forge.game.combat.Combat combat = game.getCombat(); - if (combat == null) { - return false; - } - - // Check if player is being attacked directly - if (!combat.getAttackersOf(p).isEmpty()) { - return true; - } - - // Check if any planeswalkers or battles controlled by the player are being attacked - for (forge.game.GameEntity defender : combat.getDefenders()) { - if (defender instanceof forge.game.card.Card) { - forge.game.card.Card card = (forge.game.card.Card) defender; - if (card.getController().equals(p) && !combat.getAttackersOf(defender).isEmpty()) { - return true; - } - } - } - - return false; - } - - private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { - PlayerView pv = p.getView(); - - for (PlayerView target : si.getTargetPlayers()) { - if (target.equals(pv)) return true; - } - - for (CardView target : si.getTargetCards()) { - if (target.getController() != null && target.getController().equals(pv)) { - return true; - } - } - return false; - } - - /** - * Check if there's a mass removal spell on the stack that could affect the player's permanents. - * Only interrupts if the spell was cast by an opponent AND the player has permanents that match. - */ - private boolean hasMassRemovalOnStack(forge.game.Game game, forge.game.player.Player p) { - if (game.getStack().isEmpty()) { - return false; - } - - for (forge.game.spellability.SpellAbilityStackInstance si : game.getStack()) { - forge.game.spellability.SpellAbility sa = si.getSpellAbility(); - if (sa == null) continue; - - // Only interrupt for opponent's spells - if (sa.getActivatingPlayer() == null || sa.getActivatingPlayer().equals(p)) { - continue; - } - - // Check if this is a mass removal spell type - if (isMassRemovalSpell(sa, game, p)) { - return true; - } - } - return false; - } - - /** - * Determine if a spell ability is a mass removal effect that could affect the player. - */ - private boolean isMassRemovalSpell(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { - forge.game.ability.ApiType api = sa.getApi(); - if (api == null) { - return false; - } - - // Check the main ability and all sub-abilities (for modal spells like Farewell) - forge.game.spellability.SpellAbility current = sa; - while (current != null) { - if (checkSingleAbilityForMassRemoval(current, game, p)) { - return true; - } - current = current.getSubAbility(); - } - - return false; - } - - /** - * Check if a single ability (not including sub-abilities) is mass removal affecting the player. - */ - private boolean checkSingleAbilityForMassRemoval(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { - forge.game.ability.ApiType api = sa.getApi(); - if (api == null) { - return false; - } - - String apiName = api.name(); - - // DestroyAll - Wrath of God, Day of Judgment, Damnation - if ("DestroyAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - - // ChangeZoneAll with Destination=Exile or Graveyard - Farewell, Merciless Eviction - if ("ChangeZoneAll".equals(apiName)) { - String destination = sa.getParam("Destination"); - if ("Exile".equals(destination) || "Graveyard".equals(destination)) { - // Check Origin - only care about Battlefield - String origin = sa.getParam("Origin"); - if (origin != null && origin.contains("Battlefield")) { - return playerHasMatchingPermanents(sa, game, p, "ChangeType"); - } - } - } - - // DamageAll - Blasphemous Act, Chain Reaction - if ("DamageAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - - // SacrificeAll - All Is Dust, Bane of Progress - if ("SacrificeAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - - return false; - } - - /** - * Check if the player has any permanents that match the spell's filter parameter. - */ - private boolean playerHasMatchingPermanents(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p, String filterParam) { - String validFilter = sa.getParam(filterParam); - - // Get all permanents controlled by the player - forge.game.card.CardCollectionView playerPermanents = p.getCardsIn(forge.game.zone.ZoneType.Battlefield); - if (playerPermanents.isEmpty()) { - return false; // No permanents = no reason to interrupt - } - - // If no filter specified, assume it affects all permanents - if (validFilter == null || validFilter.isEmpty()) { - return true; - } - - // Check if any of the player's permanents match the filter - for (forge.game.card.Card card : playerPermanents) { - try { - if (card.isValid(validFilter.split(","), sa.getActivatingPlayer(), sa.getHostCard(), sa)) { - return true; - } - } catch (Exception e) { - // If validation fails, be conservative and assume it might affect us - return true; - } - } - - return false; - } - - private boolean isYieldExperimentalEnabled() { - return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); + return getYieldController().shouldAutoYieldForPlayer(player); } @Override public int getPlayerCount() { - return getGameView() != null && getGameView().getGame() != null - ? getGameView().getGame().getPlayers().size() - : 0; + return getYieldController().getPlayerCount(); } @Override public void declineSuggestion(PlayerView player, String suggestionType) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - if (getGameView() == null || getGameView().getGame() == null) return; - - int currentTurn = getGameView().getGame().getPhaseHandler().getTurn(); - Integer storedTurn = declinedSuggestionsTurn.get(player); - - // Reset if turn changed - if (storedTurn == null || storedTurn != currentTurn) { - declinedSuggestionsThisTurn.put(player, Sets.newHashSet()); - declinedSuggestionsTurn.put(player, currentTurn); - } - - declinedSuggestionsThisTurn.get(player).add(suggestionType); + getYieldController().declineSuggestion(player, suggestionType); } @Override public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { - player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance - if (getGameView() == null || getGameView().getGame() == null) return false; - - int currentTurn = getGameView().getGame().getPhaseHandler().getTurn(); - Integer storedTurn = declinedSuggestionsTurn.get(player); - - if (storedTurn == null || storedTurn != currentTurn) { - return false; // Turn changed, reset - } - - Set declined = declinedSuggestionsThisTurn.get(player); - return declined != null && declined.contains(suggestionType); + return getYieldController().isSuggestionDeclined(player, suggestionType); } // End auto-yield/input code diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java new file mode 100644 index 00000000000..4483d53d6ab --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -0,0 +1,739 @@ +/* + * 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.gamemodes.match; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import forge.game.GameView; +import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.trackable.TrackableTypes; +import forge.util.Localizer; + +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Manages yield state and logic for the experimental yield system. + * Handles automatic priority passing, interrupt conditions, and smart suggestions. + * + * This class is GUI-layer only and does not modify game state or network protocol. + * Each client manages its own yield state independently. + */ +public class YieldController { + + /** + * Callback interface for GUI updates and game state access. + */ + public interface YieldCallback { + void showPromptMessage(PlayerView player, String message); + void updateButtons(PlayerView player, boolean ok, boolean cancel, boolean focusOk); + void awaitNextInput(); + void cancelAwaitNextInput(); + GameView getGameView(); + } + + private final YieldCallback callback; + + // Legacy auto-pass tracking + private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); + + // Extended yield mode tracking (experimental feature) + private final Map playerYieldMode = Maps.newHashMap(); + private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set + private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set + private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? + private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set + private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? + private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? + private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); // Track phase when next phase yield was set + + // Smart suggestion decline tracking (reset each turn) + private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); + private final Map declinedSuggestionsTurn = Maps.newHashMap(); + + /** + * Create a new YieldController with the given callback for GUI updates. + * @param callback the callback interface for GUI operations + */ + public YieldController(YieldCallback callback) { + this.callback = callback; + } + + /** + * Automatically pass priority until reaching the Cleanup phase of the current turn. + * This is the legacy auto-pass behavior. + */ + public void autoPassUntilEndOfTurn(final PlayerView player) { + autoPassUntilEndOfTurn.add(player); + } + + /** + * Cancel auto-pass for the given player. + */ + public void autoPassCancel(final PlayerView player) { + if (!autoPassUntilEndOfTurn.remove(player)) { + return; + } + + // Prevent prompt getting stuck on yielding message while actually waiting for next input opportunity + callback.showPromptMessage(player, ""); + callback.updateButtons(player, false, false, false); + callback.awaitNextInput(); + } + + /** + * Check if auto-pass is active for the given player (legacy or experimental). + */ + public boolean mayAutoPass(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + return true; + } + // Check experimental yield system + return shouldAutoYieldForPlayer(player); + } + + /** + * Update the prompt message to show current yield status. + */ + public void updateAutoPassPrompt(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + callback.cancelAwaitNextInput(); + String cancelKey = getCancelShortcutDisplayText(); + callback.showPromptMessage(player, Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn", cancelKey)); + callback.updateButtons(player, false, true, false); + return; + } + + // Check experimental yield modes + YieldMode mode = playerYieldMode.get(player); + if (mode != null && mode != YieldMode.NONE) { + callback.cancelAwaitNextInput(); + Localizer loc = Localizer.getInstance(); + String cancelKey = getCancelShortcutDisplayText(); + String message = switch (mode) { + case UNTIL_NEXT_PHASE -> loc.getMessage("lblYieldingUntilNextPhase", cancelKey); + case UNTIL_STACK_CLEARS -> loc.getMessage("lblYieldingUntilStackClears", cancelKey); + case UNTIL_END_OF_TURN -> loc.getMessage("lblYieldingUntilEndOfTurn", cancelKey); + case UNTIL_YOUR_NEXT_TURN -> loc.getMessage("lblYieldingUntilYourNextTurn", cancelKey); + case UNTIL_BEFORE_COMBAT -> loc.getMessage("lblYieldingUntilBeforeCombat", cancelKey); + case UNTIL_END_STEP -> loc.getMessage("lblYieldingUntilEndStep", cancelKey); + default -> ""; + }; + callback.showPromptMessage(player, message); + callback.updateButtons(player, false, true, false); + } + } + + /** + * Set the yield mode for a player. + */ + public void setYieldMode(PlayerView player, final YieldMode mode) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + if (!isYieldExperimentalEnabled()) { + // Fall back to legacy behavior for UNTIL_END_OF_TURN + if (mode == YieldMode.UNTIL_END_OF_TURN) { + autoPassUntilEndOfTurn.add(player); + } + return; + } + + if (mode == YieldMode.NONE) { + clearYieldMode(player); + return; + } + + playerYieldMode.put(player, mode); + GameView gameView = callback.getGameView(); + + // Track current phase for UNTIL_NEXT_PHASE mode + if (mode == YieldMode.UNTIL_NEXT_PHASE && gameView != null && gameView.getGame() != null) { + forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); + yieldNextPhaseStartPhase.put(player, ph.getPhase()); + } + // Track turn number for UNTIL_END_OF_TURN mode + if (mode == YieldMode.UNTIL_END_OF_TURN && gameView != null && gameView.getGame() != null) { + yieldStartTurn.put(player, gameView.getGame().getPhaseHandler().getTurn()); + } + // Track turn and phase state for UNTIL_BEFORE_COMBAT mode + if (mode == YieldMode.UNTIL_BEFORE_COMBAT && gameView != null && gameView.getGame() != null) { + forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); + yieldCombatStartTurn.put(player, ph.getTurn()); + forge.game.phase.PhaseType phase = ph.getPhase(); + boolean atOrAfterCombat = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); + } + // Track turn and phase state for UNTIL_END_STEP mode + if (mode == YieldMode.UNTIL_END_STEP && gameView != null && gameView.getGame() != null) { + forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); + yieldEndStepStartTurn.put(player, ph.getTurn()); + forge.game.phase.PhaseType phase = ph.getPhase(); + boolean atOrAfterEndStep = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); + } + // Track if UNTIL_YOUR_NEXT_TURN was started during our turn + if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN && gameView != null && gameView.getGame() != null) { + forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); + forge.game.player.Player playerObj = gameView.getGame().getPlayer(player); + boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); + } + } + + /** + * Clear yield mode for a player. + */ + public void clearYieldMode(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + playerYieldMode.remove(player); + yieldStartTurn.remove(player); + yieldCombatStartTurn.remove(player); + yieldCombatStartedAtOrAfterCombat.remove(player); + yieldEndStepStartTurn.remove(player); + yieldEndStepStartedAtOrAfterEndStep.remove(player); + yieldYourTurnStartedDuringOurTurn.remove(player); + yieldNextPhaseStartPhase.remove(player); + autoPassUntilEndOfTurn.remove(player); // Legacy compatibility + + callback.showPromptMessage(player, ""); + callback.updateButtons(player, false, false, false); + callback.awaitNextInput(); + } + + /** + * Get the current yield mode for a player. + */ + public YieldMode getYieldMode(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + // Check legacy auto-pass first + if (autoPassUntilEndOfTurn.contains(player)) { + return YieldMode.UNTIL_END_OF_TURN; + } + YieldMode mode = playerYieldMode.get(player); + return mode != null ? mode : YieldMode.NONE; + } + + /** + * Check if auto-yield should be active for a player based on current game state. + */ + public boolean shouldAutoYieldForPlayer(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + // Check legacy system first + if (autoPassUntilEndOfTurn.contains(player)) { + return true; + } + + if (!isYieldExperimentalEnabled()) { + return false; + } + + YieldMode mode = playerYieldMode.get(player); + if (mode == null || mode == YieldMode.NONE) { + return false; + } + + // Check interrupt conditions + if (shouldInterruptYield(player)) { + clearYieldMode(player); + return false; + } + + GameView gameView = callback.getGameView(); + if (gameView == null || gameView.getGame() == null) { + return false; + } + + forge.game.Game game = gameView.getGame(); + forge.game.phase.PhaseHandler ph = game.getPhaseHandler(); + + return switch (mode) { + case UNTIL_NEXT_PHASE -> { + forge.game.phase.PhaseType startPhase = yieldNextPhaseStartPhase.get(player); + forge.game.phase.PhaseType currentPhase = ph.getPhase(); + if (startPhase == null) { + yieldNextPhaseStartPhase.put(player, currentPhase); + yield true; + } + if (currentPhase != startPhase) { + clearYieldMode(player); + yield false; + } + yield true; + } + case UNTIL_STACK_CLEARS -> { + boolean stackEmpty = game.getStack().isEmpty() && !game.getStack().hasSimultaneousStackEntries(); + if (stackEmpty) { + clearYieldMode(player); + yield false; + } + yield true; + } + case UNTIL_END_OF_TURN -> { + // Yield until end of the turn when yield was set - clear when turn number changes + Integer startTurn = yieldStartTurn.get(player); + int currentTurn = ph.getTurn(); + if (startTurn == null) { + // Turn wasn't tracked when yield was set - track it now + yieldStartTurn.put(player, currentTurn); + yield true; + } + if (currentTurn > startTurn) { + clearYieldMode(player); + yield false; + } + yield true; + } + case UNTIL_YOUR_NEXT_TURN -> { + // Yield until our turn starts + forge.game.player.Player playerObj = game.getPlayer(player); + boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + Boolean startedDuringOurTurn = yieldYourTurnStartedDuringOurTurn.get(player); + + if (startedDuringOurTurn == null) { + // Tracking wasn't set - initialize it now + yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); + startedDuringOurTurn = isOurTurn; + } + + if (isOurTurn) { + // If we started during our turn, we need to wait until it's our turn AGAIN + // (i.e., we left our turn and came back) + // If we started during opponent's turn, stop when we reach our turn + if (!Boolean.TRUE.equals(startedDuringOurTurn)) { + clearYieldMode(player); + yield false; + } + } else { + // Not our turn - if we started during our turn, mark that we've left it + if (Boolean.TRUE.equals(startedDuringOurTurn)) { + // We've left our turn, now waiting for it to come back + yieldYourTurnStartedDuringOurTurn.put(player, false); + } + } + yield true; + } + case UNTIL_BEFORE_COMBAT -> { + forge.game.phase.PhaseType phase = ph.getPhase(); + Integer startTurn = yieldCombatStartTurn.get(player); + Boolean startedAtOrAfterCombat = yieldCombatStartedAtOrAfterCombat.get(player); + int currentTurn = ph.getTurn(); + + if (startTurn == null) { + // Tracking wasn't set - initialize it now + yieldCombatStartTurn.put(player, currentTurn); + boolean atOrAfterCombat = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); + startTurn = currentTurn; + startedAtOrAfterCombat = atOrAfterCombat; + } + + // Check if we should stop: we're at or past combat on a DIFFERENT turn than when we started, + // OR we're at combat on the SAME turn but we started BEFORE combat + boolean atOrAfterCombatNow = phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + + if (atOrAfterCombatNow) { + boolean differentTurn = currentTurn > startTurn; + boolean sameTurnButStartedBeforeCombat = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterCombat); + + if (differentTurn || sameTurnButStartedBeforeCombat) { + clearYieldMode(player); + yield false; + } + } + yield true; + } + case UNTIL_END_STEP -> { + forge.game.phase.PhaseType phase = ph.getPhase(); + Integer startTurn = yieldEndStepStartTurn.get(player); + Boolean startedAtOrAfterEndStep = yieldEndStepStartedAtOrAfterEndStep.get(player); + int currentTurn = ph.getTurn(); + + if (startTurn == null) { + // Tracking wasn't set - initialize it now + yieldEndStepStartTurn.put(player, currentTurn); + boolean atOrAfterEndStep = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); + startTurn = currentTurn; + startedAtOrAfterEndStep = atOrAfterEndStep; + } + + // Check if we should stop: we're at or past end step on a DIFFERENT turn than when we started, + // OR we're at end step on the SAME turn but we started BEFORE end step + boolean atOrAfterEndStepNow = phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + + if (atOrAfterEndStepNow) { + boolean differentTurn = currentTurn > startTurn; + boolean sameTurnButStartedBeforeEndStep = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterEndStep); + + if (differentTurn || sameTurnButStartedBeforeEndStep) { + clearYieldMode(player); + yield false; + } + } + yield true; + } + default -> false; + }; + } + + /** + * Check if yield should be interrupted based on game conditions. + */ + private boolean shouldInterruptYield(final PlayerView player) { + GameView gameView = callback.getGameView(); + if (gameView == null || gameView.getGame() == null) { + return false; + } + + forge.game.Game game = gameView.getGame(); + forge.game.player.Player p = game.getPlayer(player); + if (p == null) { + return false; // Can't determine player, don't interrupt + } + ForgePreferences prefs = FModel.getPreferences(); + forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { + // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles + if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && + game.getCombat() != null && isBeingAttacked(game, p)) { + return true; + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { + // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles + if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && + game.getCombat() != null && isBeingAttacked(game, p)) { + return true; + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TARGETING)) { + for (forge.game.spellability.StackItemView si : gameView.getStack()) { + if (targetsPlayerOrPermanents(si, p)) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + if (!game.getStack().isEmpty()) { + forge.game.spellability.SpellAbility topSa = game.getStack().peekAbility(); + // Exclude triggered abilities - if they target you, the "targeting" setting handles that + if (topSa != null && !topSa.isTrigger() && !topSa.getActivatingPlayer().equals(p)) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { + if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { + YieldMode mode = playerYieldMode.get(player); + // Don't interrupt UNTIL_END_OF_TURN on our own turn + if (!(mode == YieldMode.UNTIL_END_OF_TURN && game.getPhaseHandler().getPlayerTurn().equals(p))) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { + if (hasMassRemovalOnStack(game, p)) { + return true; + } + } + + return false; + } + + /** + * Check if the player is being attacked (directly or via planeswalkers/battles). + */ + private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p) { + forge.game.combat.Combat combat = game.getCombat(); + if (combat == null) { + return false; + } + + // Check if player is being attacked directly + if (!combat.getAttackersOf(p).isEmpty()) { + return true; + } + + // Check if any planeswalkers or battles controlled by the player are being attacked + for (forge.game.GameEntity defender : combat.getDefenders()) { + if (defender instanceof forge.game.card.Card) { + forge.game.card.Card card = (forge.game.card.Card) defender; + if (card.getController().equals(p) && !combat.getAttackersOf(defender).isEmpty()) { + return true; + } + } + } + + return false; + } + + /** + * Check if a stack item targets the player or their permanents. + */ + private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { + PlayerView pv = p.getView(); + + for (PlayerView target : si.getTargetPlayers()) { + if (target.equals(pv)) return true; + } + + for (CardView target : si.getTargetCards()) { + if (target.getController() != null && target.getController().equals(pv)) { + return true; + } + } + return false; + } + + /** + * Check if there's a mass removal spell on the stack that could affect the player's permanents. + * Only interrupts if the spell was cast by an opponent AND the player has permanents that match. + */ + private boolean hasMassRemovalOnStack(forge.game.Game game, forge.game.player.Player p) { + if (game.getStack().isEmpty()) { + return false; + } + + for (forge.game.spellability.SpellAbilityStackInstance si : game.getStack()) { + forge.game.spellability.SpellAbility sa = si.getSpellAbility(); + if (sa == null) continue; + + // Only interrupt for opponent's spells + if (sa.getActivatingPlayer() == null || sa.getActivatingPlayer().equals(p)) { + continue; + } + + // Check if this is a mass removal spell type + if (isMassRemovalSpell(sa, game, p)) { + return true; + } + } + return false; + } + + /** + * Determine if a spell ability is a mass removal effect that could affect the player. + */ + private boolean isMassRemovalSpell(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { + forge.game.ability.ApiType api = sa.getApi(); + if (api == null) { + return false; + } + + // Check the main ability and all sub-abilities (for modal spells like Farewell) + forge.game.spellability.SpellAbility current = sa; + while (current != null) { + if (checkSingleAbilityForMassRemoval(current, game, p)) { + return true; + } + current = current.getSubAbility(); + } + + return false; + } + + /** + * Check if a single ability (not including sub-abilities) is mass removal affecting the player. + */ + private boolean checkSingleAbilityForMassRemoval(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { + forge.game.ability.ApiType api = sa.getApi(); + if (api == null) { + return false; + } + + String apiName = api.name(); + + // DestroyAll - Wrath of God, Day of Judgment, Damnation + if ("DestroyAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + // ChangeZoneAll with Destination=Exile or Graveyard - Farewell, Merciless Eviction + if ("ChangeZoneAll".equals(apiName)) { + String destination = sa.getParam("Destination"); + if ("Exile".equals(destination) || "Graveyard".equals(destination)) { + // Check Origin - only care about Battlefield + String origin = sa.getParam("Origin"); + if (origin != null && origin.contains("Battlefield")) { + return playerHasMatchingPermanents(sa, game, p, "ChangeType"); + } + } + } + + // DamageAll - Blasphemous Act, Chain Reaction + if ("DamageAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + // SacrificeAll - All Is Dust, Bane of Progress + if ("SacrificeAll".equals(apiName)) { + return playerHasMatchingPermanents(sa, game, p, "ValidCards"); + } + + return false; + } + + /** + * Check if the player has any permanents that match the spell's filter parameter. + */ + private boolean playerHasMatchingPermanents(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p, String filterParam) { + String validFilter = sa.getParam(filterParam); + + // Get all permanents controlled by the player + forge.game.card.CardCollectionView playerPermanents = p.getCardsIn(forge.game.zone.ZoneType.Battlefield); + if (playerPermanents.isEmpty()) { + return false; // No permanents = no reason to interrupt + } + + // If no filter specified, assume it affects all permanents + if (validFilter == null || validFilter.isEmpty()) { + return true; + } + + // Check if any of the player's permanents match the filter + for (forge.game.card.Card card : playerPermanents) { + try { + if (card.isValid(validFilter.split(","), sa.getActivatingPlayer(), sa.getHostCard(), sa)) { + return true; + } + } catch (Exception e) { + // If validation fails, be conservative and assume it might affect us + return true; + } + } + + return false; + } + + /** + * Check if experimental yield options are enabled in preferences. + */ + private boolean isYieldExperimentalEnabled() { + return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + /** + * Get the total number of players in the game. + */ + public int getPlayerCount() { + GameView gameView = callback.getGameView(); + return gameView != null && gameView.getGame() != null + ? gameView.getGame().getPlayers().size() + : 0; + } + + /** + * Mark a suggestion as declined for the current turn. + */ + public void declineSuggestion(PlayerView player, String suggestionType) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + GameView gameView = callback.getGameView(); + if (gameView == null || gameView.getGame() == null) return; + + int currentTurn = gameView.getGame().getPhaseHandler().getTurn(); + Integer storedTurn = declinedSuggestionsTurn.get(player); + + // Reset if turn changed + if (storedTurn == null || storedTurn != currentTurn) { + declinedSuggestionsThisTurn.put(player, Sets.newHashSet()); + declinedSuggestionsTurn.put(player, currentTurn); + } + + declinedSuggestionsThisTurn.get(player).add(suggestionType); + } + + /** + * Check if a suggestion has been declined for the current turn. + */ + public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + GameView gameView = callback.getGameView(); + if (gameView == null || gameView.getGame() == null) return false; + + int currentTurn = gameView.getGame().getPhaseHandler().getTurn(); + Integer storedTurn = declinedSuggestionsTurn.get(player); + + if (storedTurn == null || storedTurn != currentTurn) { + return false; // Turn changed, reset + } + + Set declined = declinedSuggestionsThisTurn.get(player); + return declined != null && declined.contains(suggestionType); + } + + /** + * Check if the legacy auto-pass is in the set (for AbstractGuiGame internal use). + */ + public boolean isInLegacyAutoPass(PlayerView player) { + return autoPassUntilEndOfTurn.contains(player); + } + + /** + * Remove a player from legacy auto-pass (for AbstractGuiGame internal use). + */ + public void removeFromLegacyAutoPass(PlayerView player) { + autoPassUntilEndOfTurn.remove(player); + } + + /** + * Get the display text for the yield cancel keyboard shortcut. + * @return Human-readable shortcut text, e.g., "Escape" or "Ctrl+Escape" + */ + public String getCancelShortcutDisplayText() { + String codeString = FModel.getPreferences().getPref(FPref.SHORTCUT_YIELD_CANCEL); + if (codeString == null || codeString.isEmpty()) { + return ""; + } + List codes = new ArrayList<>(Arrays.asList(codeString.trim().split(" "))); + List displayText = new ArrayList<>(); + for (String s : codes) { + if (!s.isEmpty()) { + try { + displayText.add(KeyEvent.getKeyText(Integer.parseInt(s))); + } catch (NumberFormatException e) { + displayText.add(s); + } + } + } + return String.join("+", displayText); + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java index 9a59a40cee3..c01ed6a3733 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java @@ -23,6 +23,7 @@ */ public enum YieldMode { NONE("No auto-yield"), + UNTIL_NEXT_PHASE("Yield until next phase"), UNTIL_STACK_CLEARS("Yield until stack clears"), UNTIL_END_OF_TURN("Yield until end of turn"), UNTIL_YOUR_NEXT_TURN("Yield until your next turn"), 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 c4ccac66c7f..fb16741142e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -92,8 +92,6 @@ public enum ProtocolMethod { selectButtonCancel (Mode.CLIENT, Void.TYPE), selectAbility (Mode.CLIENT, Void.TYPE, SpellAbilityView.class), passPriorityUntilEndOfTurn(Mode.CLIENT, Void.TYPE), - yieldUntilStackClears (Mode.CLIENT, Void.TYPE), - yieldUntilYourNextTurn (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 67f48a7a813..57bec3d0aee 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 @@ -73,31 +73,6 @@ public void passPriorityUntilEndOfTurn() { send(ProtocolMethod.passPriorityUntilEndOfTurn); } - @Override - public void yieldUntilStackClears() { - send(ProtocolMethod.yieldUntilStackClears); - } - - @Override - public void yieldUntilYourNextTurn() { - send(ProtocolMethod.yieldUntilYourNextTurn); - } - - @Override - public void yieldUntilBeforeCombat() { - // Stub for network play - yield modes handled locally - } - - @Override - public void yieldUntilEndStep() { - // Stub for network play - yield modes handled locally - } - - @Override - public void yieldUntilEndOfTurn() { - // Stub for network play - yield modes handled locally - } - @Override public void passPriority() { send(ProtocolMethod.passPriority); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index fb687e92b67..4367ca77bb2 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -28,17 +28,6 @@ public interface IGameController { void passPriorityUntilEndOfTurn(); - // Extended yield methods (experimental feature) - void yieldUntilStackClears(); - - void yieldUntilYourNextTurn(); - - void yieldUntilBeforeCombat(); - - void yieldUntilEndStep(); - - void yieldUntilEndOfTurn(); - void selectPlayer(PlayerView playerView, ITriggerEvent triggerEvent); boolean selectCard(CardView cardView, List otherCardViewsToSelect, ITriggerEvent triggerEvent); 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 47a9339abe9..3e6c5d99856 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -301,11 +301,13 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), - SHORTCUT_YIELD_UNTIL_END_OF_TURN("112"), // F1 key - SHORTCUT_YIELD_UNTIL_STACK_CLEARS("113"), // F2 key - SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("114"), // F3 key - SHORTCUT_YIELD_UNTIL_END_STEP("115"), // F4 key + SHORTCUT_YIELD_UNTIL_NEXT_PHASE("112"), // F1 key + SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("113"), // F2 key + SHORTCUT_YIELD_UNTIL_END_STEP("114"), // F3 key + SHORTCUT_YIELD_UNTIL_END_OF_TURN("115"), // F4 key SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116"), // F5 key + SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117"), // F6 key + SHORTCUT_YIELD_CANCEL("27"), // ESC key LAST_IMPORTED_CUBE_ID(""); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index b07e6370119..4baab246ecb 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3280,26 +3280,6 @@ public void autoPassCancel() { getGui().autoPassCancel(getLocalPlayerView()); } - public void yieldUntilStackClears() { - getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_STACK_CLEARS); - } - - public void yieldUntilYourNextTurn() { - getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_YOUR_NEXT_TURN); - } - - public void yieldUntilBeforeCombat() { - getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_BEFORE_COMBAT); - } - - public void yieldUntilEndStep() { - getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_END_STEP); - } - - public void yieldUntilEndOfTurn() { - getGui().setYieldMode(getLocalPlayerView(), forge.gamemodes.match.YieldMode.UNTIL_END_OF_TURN); - } - public int getPlayerCount() { return getGui().getPlayerCount(); } From 57466cd64e8196c1f38e7b25e309a8d2f636b90b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 23:29:33 +0000 Subject: [PATCH 13/33] Comprehensively update DOCUMENTATION.md for accuracy and completeness Applied 10 amendments to improve documentation quality: CRITICAL FIXES: 1. Add missing YieldController.java to file lists and architecture - YieldController is a core component that was completely missing - Updated "New Files" section from 3 to 4 files 2. Add comprehensive Architecture section - Component hierarchy diagram showing GUI layer organization - Detailed explanation of YieldController, AbstractGuiGame interaction - Network independence architecture with multi-player scenarios - Complete data flow diagrams for all yield operations - File organization with clear component responsibilities MODERATE IMPROVEMENTS: 3. Expand State Management section with delegation pattern - Show AbstractGuiGame's lazy initialization of YieldController - Document YieldCallback interface implementation - Add complete state map documentation from YieldController - Include mode-specific end condition table 4. Correct Modified Files count and descriptions - Updated from 14 to 13 files (removed EDocID, FButton, ProtocolMethod) - Improved descriptions to match actual implementation - Clarified that network protocol has no yield-specific changes MINOR ENHANCEMENTS: 5. Clarify Yield Options Panel button layout - Document 2-row layout structure - Add visual feedback section - Note cleanup/discard phase button disabling 6. Add PlayerView lookup technical detail - Document TrackableTypes.PlayerViewType.lookup() usage - Explain Map key consistency requirement 7. Expand Smart Yield Suggestions section - Add preference names for each suggestion type - Document auto-suppression behavior in detail - Clarify when suggestions appear vs. don't appear - Explain yield button priority over suggestions 8. Add Initial Implementation changelog entry - Document YieldController architecture rationale - Explain delegate and callback pattern choices - Note lazy initialization strategy 9. Fix typo: Make "disabled" bold for clarity in interrupt section 10. Add comprehensive Troubleshooting section - Yield activation issues - Unexpected yield clearing - Smart suggestion behavior - Network play expectations - Performance considerations The documentation now accurately reflects the codebase implementation, includes the requested Architecture section explaining component interactions, and provides helpful troubleshooting guidance for users. https://claude.ai/code/session_01SBGxSAqnqbpVuEsinNtrnZ --- DOCUMENTATION.md | 467 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 398 insertions(+), 69 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index abeb09649b6..1d2eec798f6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -30,16 +30,22 @@ Extended yield options that allow players to automatically pass priority until s ### Access Methods -1. **Yield Options Panel**: A dockable panel with dedicated yield buttons: +1. **Yield Options Panel**: A dockable panel with dedicated yield buttons in a 2-row layout: + + **Row 1:** - **Next Phase** - Yield until next phase begins - **Combat** - Yield until before combat - **End Step** - Yield until end step - - **Next Turn** - Yield until next turn + + **Row 2:** + - **End Turn** - Yield until next turn - **Your Turn** - Yield until your next turn (only visible in 3+ player games) - **Clear Stack** - Yield until stack clears (only enabled when stack has items) - - Buttons are blue by default, red when that yield mode is active + + **Visual Feedback:** + - Buttons are **blue** by default, **red** when that yield mode is active - Panel appears as a tab alongside the Stack panel when experimental yields are enabled - - Buttons are disabled during mulligan and pre-game phases + - All buttons disabled during mulligan, pre-game, and cleanup/discard phases 2. **Right-Click Menu**: Right-click the "End Turn" button to see yield options (configurable) @@ -54,15 +60,31 @@ Extended yield options that allow players to automatically pass priority until s ### Smart Yield Suggestions -When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** with Accept/Decline buttons: +When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** (not modal dialogs) with Accept/Decline buttons: + +1. **Cannot respond to stack** (`YIELD_SUGGEST_STACK_YIELD`): Player has no instant-speed responses available + - Checks if stack has items + - Uses `getAllPossibleAbilities(removeUnplayable=true)` to verify no responses + - Suggests `UNTIL_STACK_CLEARS` mode -1. **Cannot respond to stack**: Player has no instant-speed responses available (checks `getAllPossibleAbilities()`) -2. **No mana available**: Player has cards but no mana sources untapped (not on player's turn) -3. **No actions available**: No playable cards in hand and no activatable non-mana abilities (not on player's turn) +2. **No mana available** (`YIELD_SUGGEST_NO_MANA`): Player has cards but no mana sources untapped + - Only triggers when not on player's turn + - Checks for untapped lands with mana abilities or mana in pool + - Suggests default yield mode (based on game type) -Each suggestion can be individually enabled/disabled. +3. **No actions available** (`YIELD_SUGGEST_NO_ACTIONS`): No playable cards in hand and no activatable non-mana abilities + - Only triggers when not on player's turn and stack is empty + - Uses `getAllPossibleAbilities(removeUnplayable=true)` to verify + - Suggests default yield mode (based on game type) -**Note:** Suggestions will not appear if the player is already yielding. +**Suggestion Behavior:** +- Each suggestion type can be individually enabled/disabled via preferences +- Suggestions will **not appear** if: + - The player is already yielding + - The suggestion was declined earlier in the same turn (auto-suppression) +- Declining a suggestion shows hint: "(Declining disables this prompt until next turn)" +- Suppression automatically resets when turn number changes +- If a yield button is clicked while a suggestion is showing, the clicked yield mode takes precedence ### Interrupt Conditions @@ -73,7 +95,7 @@ Yield modes can be configured to automatically cancel when: - **You or your permanents** are targeted by a spell/ability (default: ON) - An opponent casts any spell (default: OFF) - Combat begins (default: OFF) -- Cards are revealed or choices are made (default: OFF) - when disabled, reveal dialogs and opponent choice notifications are auto-dismissed during yield +- Cards are revealed or choices are made (default: OFF) - when **disabled**, reveal dialogs and opponent choice notifications are auto-dismissed during yield - Mass removal spell cast by opponent (default: ON) - detects DestroyAll, ChangeZoneAll (exile/graveyard), DamageAll, SacrificeAll effects; only interrupts if you have permanents matching the spell's filter **Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. @@ -93,40 +115,243 @@ Once enabled: ## Technical Implementation -### Architecture +### Architecture Overview -All changes are in the **GUI layer only** - no modifications to core game logic, rules engine, or network protocol: +The yield system is implemented entirely in the **GUI layer** with zero changes to the core game engine or network protocol. This design ensures backward compatibility and allows each client to manage its own yield preferences independently. + +#### Component Hierarchy + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GUI Layer (Client) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Desktop UI Components (forge-gui-desktop) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ VYield │ │ CYield │ │ VPrompt │ │ │ +│ │ │ (View) │ │ (Ctrl) │ │ (Menu) │ │ │ +│ │ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ │ +│ └────────┼─────────────┼─────────────┼─────────────────┘ │ +│ │ │ │ │ +│ ┌────────┴─────────────┴─────────────┴─────────────────┐ │ +│ │ Shared GUI Logic (forge-gui) │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ AbstractGuiGame │ │ │ +│ │ │ (Implements IGuiGame interface) │ │ │ +│ │ │ │ │ │ +│ │ │ ┌─────────────────────────────────┐ │ │ │ +│ │ │ │ YieldController (delegate) │ │ │ │ +│ │ │ │ - State management │ │ │ │ +│ │ │ │ - Interrupt logic │ │ │ │ +│ │ │ │ - End condition checks │ │ │ │ +│ │ │ └─────────────┬───────────────────┘ │ │ │ +│ │ │ ▲ │ │ │ +│ │ │ │ YieldCallback │ │ │ +│ │ │ │ (for GUI updates) │ │ │ +│ │ └────────────────┼───────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────┴───────────────────────────────┐ │ │ +│ │ │ InputPassPriority │ │ │ +│ │ │ - Smart suggestions │ │ │ +│ │ │ - Prompt integration │ │ │ +│ │ └────────────────────────────────────────────────┘ │ │ +│ └────────────────────────┬───────────────────────────────┘ │ +└───────────────────────────┼──────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ IGameController Interface │ + │ (Priority pass abstraction) │ + └──────────────┬──────────────────────┘ + │ + ┌──────────────┴──────────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ PlayerControllerHuman│ │ NetGameController │ +│ (Local games) │ │ (Network games) │ +└──────────────────────┘ └──────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ Network Protocol │ + │ (unchanged) │ + │ - Standard priority │ + │ pass messages only │ + └──────────────────────────┘ +``` + +#### Key Components + +**1. YieldController** (New - `forge-gui/YieldController.java`) +- **Purpose**: Core yield logic and state management +- **Responsibilities**: + - Manages yield state maps for each player + - Implements interrupt condition checking + - Evaluates mode-specific end conditions + - Provides YieldCallback interface for GUI updates +- **State Tracking**: Uses Maps keyed by PlayerView to track: + - `playerYieldMode` - Current yield mode per player + - `yieldStartTurn` - Turn number when yield was set + - `yieldCombatStartTurn` - Turn when combat yield was set + - `yieldNextPhaseStartPhase` - Phase when next phase yield was set + - `declinedSuggestionsThisTurn` - Declined suggestion tracking +- **Design Pattern**: Uses callback pattern to decouple from GUI + +**2. AbstractGuiGame** (`forge-gui/AbstractGuiGame.java`) +- **Purpose**: GUI game implementation that delegates to YieldController +- **Responsibilities**: + - Lazily initializes YieldController with callback implementation + - Exposes yield methods through IGuiGame interface + - Provides callback implementations for GUI updates +- **Delegation**: All yield operations delegate to `getYieldController()` + ```java + public void setYieldMode(PlayerView player, YieldMode mode) { + getYieldController().setYieldMode(player, mode); + } + ``` +- **Design Pattern**: Delegate pattern for separation of concerns + +**3. InputPassPriority** (`forge-gui/InputPassPriority.java`) +- **Purpose**: Priority pass input handler with smart suggestions +- **Responsibilities**: + - Detects situations where yield suggestions are helpful + - Integrates suggestions into prompt area (not modal dialogs) + - Tracks pending suggestion state + - Respects decline tracking (suppression per turn) +- **Integration**: Checks experimental yield flag and player yield state before showing suggestions + +**4. Desktop UI Components** (`forge-gui-desktop/`) +- **VYield**: Yield panel view with 6 buttons in 2-row layout + - Row 1: Next Phase | Combat | End Step + - Row 2: End Turn | Your Turn | Clear Stack + - Uses `FButton.setUseHighlightMode(true)` for blue/red coloring + - Dynamic tooltip updates with keyboard shortcuts +- **CYield**: Controller that registers action listeners and updates button states +- **VPrompt**: Right-click menu on End Turn button (if preference enabled) + +#### Network Independence + +**Client-Local State:** +- Each client maintains its own `YieldController` instance +- Yield modes are **never synchronized** between clients +- No yield state is sent over the network + +**Protocol Compatibility:** +- Yield system only affects **when** priority is passed, not **how** +- Uses existing `selectButtonOk()` / `passPriority()` protocol methods +- Network layer sees only standard priority pass messages +- NetGameController implements IGameController with zero yield-specific methods + +**Example Multi-Player Scenario:** +``` +3-Player Game: +- Player A: Sets UNTIL_YOUR_NEXT_TURN (auto-passing in background) +- Player B: Sets UNTIL_COMBAT (auto-passing in background) +- Player C: Manual priority passing + +Network traffic from all three players: +- A sends: passPriority message (automated by yield system) +- B sends: passPriority message (automated by yield system) +- C sends: passPriority message (manual click) + +Server behavior: Identical for all three - no awareness of yield state +``` -**Key Point: Network Independence** -- The yield system operates entirely at the GUI/client layer -- It automates *when* to pass priority, not *how* priority is passed -- Standard priority pass messages are sent through the existing network protocol -- Each client manages its own yield state independently - no yield state is synchronized between clients -- Compatible with existing network play without any protocol changes +#### Data Flow + +**1. User Activates Yield:** +``` +User clicks yield button (VYield) + ↓ +CYield calls matchUI.setYieldMode(player, mode) + ↓ +AbstractGuiGame.setYieldMode(player, mode) + ↓ +YieldController.setYieldMode(player, mode) + ├─ Stores mode in playerYieldMode map + ├─ Initializes tracking (turn number, phase, etc.) + └─ Calls callback.showPromptMessage("Yielding until...") + ↓ +CYield calls gameController.selectButtonOk() + ↓ +Priority is passed (network message if online) +``` + +**2. Auto-Yield Check (Game Loop):** +``` +Priority prompt would normally appear + ↓ +YieldController.shouldAutoYieldForPlayer(player) + ├─ Check if yield mode is active + ├─ Check interrupt conditions (attacks, targeting, mass removal, etc.) + ├─ Check mode-specific end conditions + └─ Return true/false + ↓ +If true: Automatically call selectButtonOk() (pass priority) +If false: Show priority prompt to user +``` + +**3. Interrupt Condition:** +``` +Game event occurs (e.g., player is attacked) + ↓ +YieldController.shouldInterruptYield(player) + ├─ Check preference settings + ├─ Check if condition affects this specific player + └─ Return true if should interrupt + ↓ +If true: YieldController.clearYieldMode(player) + ├─ Remove from all tracking maps + └─ Call callback.showPromptMessage("") + ↓ +User sees normal priority prompt +``` + +**4. Smart Suggestion Flow:** +``` +Priority prompt triggered + ↓ +InputPassPriority.showMessage() + ├─ Check if experimental yield enabled + ├─ Check if already yielding (skip if yes) + ├─ Check each suggestion condition (stack, no mana, no actions) + ├─ Check if suggestion was declined this turn + └─ Show suggestion or normal prompt + ↓ +User accepts suggestion: + ├─ Set yield mode + └─ Pass priority + ↓ +User declines suggestion: + ├─ Track decline in declinedSuggestionsThisTurn + └─ Show normal prompt +``` + +#### File Organization ``` forge-gui/ (shared GUI code) -├── YieldMode.java # New enum for yield modes -├── AbstractGuiGame.java # Yield state tracking & logic +├── YieldMode.java # Yield mode enum definitions +├── YieldController.java # Core yield logic and state management +├── AbstractGuiGame.java # Yield delegation and GUI integration ├── InputPassPriority.java # Smart suggestion prompts -├── IGuiGame.java # Interface updates -├── IGameController.java # Controller interface -├── PlayerControllerHuman.java # Controller implementation -├── ForgePreferences.java # New preferences -├── NetGameController.java # Controller interface implementation (no protocol changes) -├── ProtocolMethod.java # Interface method declarations -└── en-US.properties # Localization +├── IGuiGame.java # Interface with yield methods +├── IGameController.java # Controller interface (no yield-specific methods) +├── PlayerControllerHuman.java # Local game controller implementation +├── ForgePreferences.java # 13 new preferences +├── NetGameController.java # Network controller (no protocol changes) +└── en-US.properties # 30+ localization strings forge-gui-desktop/ (desktop-specific) ├── VYield.java # Yield Options panel view (NEW) ├── CYield.java # Yield Options panel controller (NEW) -├── EDocID.java # Added REPORT_YIELD doc ID -├── VPrompt.java # Right-click menu -├── VMatchUI.java # Dynamic panel visibility -├── CMatchUI.java # Yield panel registration -├── GameMenu.java # Yield Options submenu -├── FButton.java # Added highlight mode for buttons -└── KeyboardShortcuts.java # New shortcuts +├── VPrompt.java # Right-click menu on End Turn button +├── VMatchUI.java # Dynamic panel visibility based on preferences +├── CMatchUI.java # Yield panel registration and updates +├── GameMenu.java # Yield Options submenu with Display Options +└── KeyboardShortcuts.java # F-key shortcuts for yield modes + +forge-gui-desktop/res/layouts/ +└── match.xml # Added REPORT_YIELD to default layout ``` ### Key Design Decisions @@ -136,6 +361,7 @@ forge-gui-desktop/ (desktop-specific) 3. **Network independent**: Yield state is client-local; no synchronization needed 4. **Backward compatible**: Existing Ctrl+E behavior unchanged 5. **Individual toggles**: Each suggestion/interrupt can be configured separately +6. **PlayerView consistency**: All yield methods use `TrackableTypes.PlayerViewType.lookup(player)` to ensure Map key consistency and prevent instance mismatch bugs ### End Turn Button Behavior @@ -157,65 +383,116 @@ The "End Turn" button (Cancel button during priority) has different behavior dep ### State Management +All yield state is managed by `YieldController` and accessed through `AbstractGuiGame`: + ```java // In AbstractGuiGame.java +private YieldController yieldController; + +private YieldController getYieldController() { + if (yieldController == null) { + yieldController = new YieldController(new YieldController.YieldCallback() { + @Override + public void showPromptMessage(PlayerView player, String message) { + AbstractGuiGame.this.showPromptMessage(player, message); + } + @Override + public void updateButtons(PlayerView player, boolean ok, boolean cancel, boolean focusOk) { + AbstractGuiGame.this.updateButtons(player, ok, cancel, focusOk); + } + @Override + public void awaitNextInput() { + AbstractGuiGame.this.awaitNextInput(); + } + @Override + public void cancelAwaitNextInput() { + AbstractGuiGame.this.cancelAwaitNextInput(); + } + @Override + public GameView getGameView() { + return AbstractGuiGame.this.getGameView(); + } + }); + } + return yieldController; +} + +// Delegation methods +public void setYieldMode(PlayerView player, YieldMode mode) { + getYieldController().setYieldMode(player, mode); +} +``` + +**YieldController Internal State Maps:** +```java +// In YieldController.java private final Map playerYieldMode = Maps.newHashMap(); -private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set -private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set -private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? -private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set -private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? -private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? -private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); // Track phase when next phase yield was set +private final Map yieldStartTurn = Maps.newHashMap(); +private final Map yieldCombatStartTurn = Maps.newHashMap(); +private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); +private final Map yieldEndStepStartTurn = Maps.newHashMap(); +private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); +private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); +private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); // Smart suggestion decline tracking (resets each turn) private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); private final Map declinedSuggestionsTurn = Maps.newHashMap(); + +// Legacy auto-pass tracking (backward compatibility) +private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); ``` -The `shouldAutoYieldForPlayer()` method checks: -1. Legacy auto-pass set (backward compatibility) +**Key Implementation Details:** + +1. **PlayerView Lookup**: All methods use `TrackableTypes.PlayerViewType.lookup(player)` to ensure map key consistency +2. **Callback Pattern**: YieldController uses callback interface to avoid direct GUI dependencies +3. **Lazy Initialization**: YieldController is created on first access to avoid overhead when feature is disabled +4. **Turn-Based Reset**: Declined suggestions automatically reset when turn number changes + +The `shouldAutoYieldForPlayer()` method evaluates: +1. Legacy auto-pass state (backward compatibility) 2. Current yield mode -3. Interrupt conditions -4. Mode-specific end conditions: - - `UNTIL_NEXT_PHASE`: Clears when phase changes (tracked via `yieldNextPhaseStartPhase`) - - `UNTIL_STACK_CLEARS`: Clears when stack is empty AND no simultaneous stack entries - - `UNTIL_END_OF_TURN`: Clears when turn number changes (tracked via `yieldStartTurn`) - - `UNTIL_YOUR_NEXT_TURN`: Clears when player becomes active player; if started during own turn, waits until turn comes back around - - `UNTIL_BEFORE_COMBAT`: Clears at next COMBAT_BEGIN; if started at/after combat, waits for next turn's combat - - `UNTIL_END_STEP`: Clears at next END_OF_TURN; if started at/after end step, waits for next turn's end step +3. Interrupt conditions (configured via preferences) +4. Mode-specific end conditions (see table below) + +**Mode-Specific End Conditions:** + +| Mode | Tracking State | End Condition Logic | +|------|----------------|---------------------| +| `UNTIL_NEXT_PHASE` | `yieldNextPhaseStartPhase` | Current phase ≠ start phase | +| `UNTIL_STACK_CLEARS` | None | Stack.isEmpty() && !hasSimultaneousStackEntries() | +| `UNTIL_END_OF_TURN` | `yieldStartTurn` | Current turn > start turn | +| `UNTIL_YOUR_NEXT_TURN` | `yieldYourTurnStartedDuringOurTurn` | Player becomes active player (with wrap-around logic) | +| `UNTIL_BEFORE_COMBAT` | `yieldCombatStartTurn`, `yieldCombatStartedAtOrAfterCombat` | Next COMBAT_BEGIN phase (skips current turn's combat if already passed) | +| `UNTIL_END_STEP` | `yieldEndStepStartTurn`, `yieldEndStepStartedAtOrAfterEndStep` | Next END_OF_TURN phase (skips current turn's end step if already passed) | ## Files Changed -### New Files (3) +### New Files (4) - `forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java` - Yield mode enum +- `forge-gui/src/main/java/forge/gamemodes/match/YieldController.java` - Core yield logic and state management - `forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java` - Yield panel view - `forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java` - Yield panel controller -### Modified Files (14) +### Modified Files (13) -**forge-gui (9 files):** -- `AbstractGuiGame.java` - Yield mode tracking, interrupt logic, combat yield tracking -- `InputPassPriority.java` - Smart suggestion prompts -- `IGuiGame.java` - Interface methods -- `IGameController.java` - Controller interface +**forge-gui (8 files):** +- `AbstractGuiGame.java` - Yield controller delegation, callback implementation +- `InputPassPriority.java` - Smart suggestion prompts with decline tracking +- `IGuiGame.java` - Interface methods for yield operations +- `IGameController.java` - Controller interface (no yield-specific methods) - `PlayerControllerHuman.java` - Controller implementation, reveal skip during yield - `ForgePreferences.java` - 13 new preferences -- `NetGameController.java` - Controller interface implementation (no network protocol changes) -- `ProtocolMethod.java` - Interface method declarations +- `NetGameController.java` - Controller interface implementation (no protocol changes) - `en-US.properties` - 30+ localization strings -**forge-gui-desktop (7 files):** -- `VPrompt.java` - Right-click menu on End Turn button +**forge-gui-desktop (5 files):** +- `VPrompt.java` - Right-click menu on End Turn button, ESC key handler - `VMatchUI.java` - Dynamic panel visibility based on preferences - `CMatchUI.java` - Yield panel registration and updates -- `EDocID.java` - Added REPORT_YIELD document ID -- `FButton.java` - Added highlight mode for yield button coloring - `GameMenu.java` - Yield Options submenu with Display Options -- `KeyboardShortcuts.java` - New keyboard shortcuts - -**Resources (1):** -- `match.xml` - Added REPORT_YIELD to default layout +- `KeyboardShortcuts.java` - F-key shortcuts for yield modes ## New Preferences @@ -318,6 +595,45 @@ SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 - [ ] Yield modes work correctly in network games (each client manages its own yield state) - [ ] No desync when one player uses extended yields (yield is client-local) +## Troubleshooting + +### Yield Not Working + +**Yield doesn't activate when clicking button:** +- Verify `YIELD_EXPERIMENTAL_OPTIONS` is set to `true` in preferences +- Restart Forge after changing the preference +- Yield buttons are disabled during mulligan, pre-game, and cleanup phases + +**Yield clears unexpectedly:** +- Check interrupt settings in Forge > Game > Yield Options > Interrupt Settings +- If being attacked or targeted, yield will clear (if those interrupts are enabled) +- Yield modes clear automatically when their end condition is met + +**Smart suggestions not appearing:** +- Verify individual suggestion preferences are enabled +- Suggestions don't appear if you're already yielding +- If you declined a suggestion, it won't appear again until next turn +- Suggestions only appear when experimental yields are enabled + +### Network Play Issues + +**Yield behaves differently for different players:** +- This is expected - each client manages its own yield state +- Yield preferences are client-local, not synchronized +- Each player sees their own yield settings + +**Desync concerns:** +- Yield system cannot cause desync - it's GUI-only +- Network protocol is unchanged +- Server only sees standard priority pass messages + +### Performance + +**Game feels slow when yielding:** +- This is normal - the game loop checks yield conditions on each priority check +- Performance impact is minimal (Map lookups and boolean checks) +- Consider disabling interrupt conditions you don't need to simplify checks + ## Risk Assessment ### Low Risk @@ -333,6 +649,19 @@ SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 ## Changelog +### Initial Implementation - YieldController Architecture + +**Core Design:** +1. **YieldController class** - Separated yield logic from AbstractGuiGame using delegate pattern +2. **YieldCallback interface** - Decoupled yield logic from GUI implementation for testability +3. **PlayerView lookup** - Used `TrackableTypes.PlayerViewType.lookup()` throughout for Map key consistency +4. **State tracking maps** - Separate maps for different yield modes' timing requirements + +**Design Pattern Rationale:** +- Delegate pattern allows AbstractGuiGame to remain focused on GUI coordination +- Callback interface enables testing without full GUI stack +- Lazy initialization avoids overhead when feature is disabled + ### 2026-01-30 - Yield Until Next Phase & Dynamic Hotkeys **New Feature:** From aeed733b7986968585fa41981f4ad3a8185f1eff Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Fri, 30 Jan 2026 21:00:52 +1030 Subject: [PATCH 14/33] Remove redundant PR documentation file DOCUMENTATION.md now contains all PR documentation, making the separate .documentation/YieldRework-PR.md file redundant. Co-Authored-By: Claude Opus 4.5 --- .documentation/YieldRework-PR.md | 101 ------------------------------- 1 file changed, 101 deletions(-) delete mode 100644 .documentation/YieldRework-PR.md diff --git a/.documentation/YieldRework-PR.md b/.documentation/YieldRework-PR.md deleted file mode 100644 index f55cf499c47..00000000000 --- a/.documentation/YieldRework-PR.md +++ /dev/null @@ -1,101 +0,0 @@ -# Pull Request: Experimental Yield System for Multiplayer - -**Branch:** `YieldRework` -**Target:** `master` -**Status:** Draft - -## Title - -Add experimental yield system for reduced multiplayer micromanagement - -## Summary - -This PR adds a feature-gated yield system to reduce excessive clicking in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. - -See [DOCUMENTATION.md](../DOCUMENTATION.md) for complete technical documentation. - -## Problem - -In multiplayer Magic games (3+ players), the current priority system requires excessive clicking: -- Dozens of priority passes every turn in a 4-player game -- Players must manually pass priority even when they have no possible actions -- This creates click fatigue and slows down gameplay significantly - -## Solution - -Extended yield options that automatically pass priority until specific conditions are met, with configurable interrupts for important game events. - -## Key Features - -### Yield Modes -| Mode | End Condition | Hotkey | -|------|---------------|--------| -| Next Turn | Turn number changes | F1 | -| Until Stack Clears | Stack empty (including simultaneous triggers) | F2 | -| Until Before Combat | Next COMBAT_BEGIN phase (tracks start turn/phase) | F3 | -| Until End Step | Next END_OF_TURN phase (tracks start turn/phase) | F4 | -| Until Your Next Turn | Your turn starts again (tracks if started during own turn) | F5 | - -### Access Methods -- **Yield Options Panel**: Dockable panel with dedicated yield buttons (appears with Stack panel) -- Right-click "End Turn" button for yield options menu (configurable) -- Keyboard shortcuts: F1-F5 for yield modes, ESC to cancel -- Game menu → Yield Options submenu - -### Smart Suggestions -Prompts appear when player likely cannot act: -- Cannot respond to stack (no instant-speed options) -- No mana available (cards in hand but tapped out) -- No actions available (empty hand, no abilities) - -### Interrupt Conditions (Configurable) -- Attackers declared against **you** (multiplayer-aware) -- Blockers phase when **you** are being attacked -- **You or your permanents** targeted -- Any opponent spell cast -- Combat begins -- Cards revealed (can be disabled to auto-dismiss reveal dialogs) - -## Files Changed - -**New (3):** -- `forge-gui/.../YieldMode.java` - Yield mode enum -- `forge-gui-desktop/.../VYield.java` - Yield panel view -- `forge-gui-desktop/.../CYield.java` - Yield panel controller - -**Modified (15):** -- `forge-gui`: AbstractGuiGame, InputPassPriority, IGuiGame, IGameController, PlayerControllerHuman, ForgePreferences, NetGameController, ProtocolMethod, en-US.properties -- `forge-gui-desktop`: VPrompt, VMatchUI, CMatchUI, EDocID, FButton, GameMenu, KeyboardShortcuts -- `forge-gui/res`: match.xml (default layout) - -## How to Enable - -1. Open Forge Preferences -2. Set `YIELD_EXPERIMENTAL_OPTIONS` to `true` -3. Restart the game - -## Testing Checklist - -- [ ] Feature disabled by default -- [ ] Yield modes end at correct conditions -- [ ] Multiplayer: interrupts only trigger for YOUR attacks/targeting -- [ ] Smart suggestions appear in prompt area (not modal dialogs) -- [ ] Menu checkboxes stay open when toggled -- [ ] Network play: no desync with extended yields -- [ ] Yield Options panel appears when feature enabled -- [ ] Yield buttons disabled during mulligan -- [ ] Active yield button highlighted in red -- [ ] "Interrupt on Reveal" setting works (dialogs skipped when disabled) -- [ ] Combat yield stops at correct combat (not same turn's M2) - -## Risk Assessment - -**Low Risk:** -- Feature-gated with default OFF -- No changes to `forge-game` rules engine -- Existing Ctrl+E behavior unchanged -- GUI layer changes only - -**Considerations:** -- Desktop-only (mobile not affected) -- Network protocol additions require matching client versions From e1104e61ed47ec52b5fbf075ad052110f66a80c0 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 06:53:22 +1030 Subject: [PATCH 15/33] Fix targeting interrupt not detecting sub-ability targets The yield interrupt for "targeted by spell or ability" was not triggering for Oona, Queen of the Fae's ability because the targeting is in a sub-ability (DB$ Dig), not the main ability (AB$ ChooseColor). Modified targetsPlayerOrPermanents() to recursively check sub-instances via getSubInstance(), ensuring targeting in nested sub-abilities is properly detected. Co-Authored-By: Claude Opus 4.5 --- .../main/java/forge/gamemodes/match/YieldController.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 4483d53d6ab..085be1394f9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -508,6 +508,8 @@ private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p /** * Check if a stack item targets the player or their permanents. + * Recursively checks sub-instances to handle abilities with targeting in sub-abilities + * (e.g., Oona, Queen of the Fae whose targeting is in a sub-ability). */ private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { PlayerView pv = p.getView(); @@ -521,6 +523,13 @@ private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView return true; } } + + // Recursively check sub-instances for targeting (handles abilities like Oona) + forge.game.spellability.StackItemView subInstance = si.getSubInstance(); + if (subInstance != null && targetsPlayerOrPermanents(subInstance, p)) { + return true; + } + return false; } From 85dbd6a5750e5a1f9327d089ee7218deff60eec5 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 07:02:52 +1030 Subject: [PATCH 16/33] Remove duplicate hasAvailableActions function Per PR review feedback from tool4ever: hasAvailableActions was identical to canRespondToStack. Removed the duplicate and reuse canRespondToStack in shouldShowNoActionsPrompt. Co-Authored-By: Claude Opus 4.5 --- .../match/input/InputPassPriority.java | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) 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 f9731461b54..d41712cb721 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 @@ -381,25 +381,6 @@ private boolean shouldShowNoActionsPrompt() { return false; } - return !hasAvailableActions(game, player); - } - - private boolean hasAvailableActions(Game game, Player player) { - // Check hand for actually playable spells (filters by timing, mana, etc.) - for (Card card : player.getCardsIn(ZoneType.Hand)) { - if (!card.getAllPossibleAbilities(player, true).isEmpty()) { - return true; - } - } - - // Check battlefield for activatable abilities (excluding mana abilities) - for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (!sa.isManaAbility()) { - return true; - } - } - } - return false; + return !canRespondToStack(game, player); } } From 9211d97a689e942a4b5d998dca3b1773c4792eee Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 07:33:28 +1030 Subject: [PATCH 17/33] Fix yield system for multiplayer non-host players Refactored YieldController to use network-safe GameView properties instead of gameView.getGame() which returns an unsynchronized dummy object for network clients. - Use gameView.getPhase/getTurn/getPlayerTurn/getStack/getCombat - Use CombatView and StackItemView instead of direct Game access - Added StackItemView.getApiType() for mass removal detection Co-Authored-By: Claude Opus 4.5 --- .../game/spellability/StackItemView.java | 9 + .../forge/trackable/TrackableProperty.java | 1 + .../gamemodes/match/YieldController.java | 298 ++++++++---------- 3 files changed, 142 insertions(+), 166 deletions(-) diff --git a/forge-game/src/main/java/forge/game/spellability/StackItemView.java b/forge-game/src/main/java/forge/game/spellability/StackItemView.java index ebcc09d1770..2fddae91a08 100644 --- a/forge-game/src/main/java/forge/game/spellability/StackItemView.java +++ b/forge-game/src/main/java/forge/game/spellability/StackItemView.java @@ -39,6 +39,7 @@ public StackItemView(SpellAbilityStackInstance si) { updateOptionalTrigger(si); updateSubInstance(si); updateOptionalCost(si); + updateApiType(si); } public String getKey() { @@ -97,6 +98,14 @@ void updateOptionalCost(SpellAbilityStackInstance si) { set(TrackableProperty.OptionalCosts, OptionalCostString); } + public String getApiType() { + return get(TrackableProperty.ApiType); + } + void updateApiType(SpellAbilityStackInstance si) { + SpellAbility sa = si.getSpellAbility(); + set(TrackableProperty.ApiType, sa != null && sa.getApi() != null ? sa.getApi().name() : null); + } + public int getSourceTrigger() { return get(TrackableProperty.SourceTrigger); } diff --git a/forge-game/src/main/java/forge/trackable/TrackableProperty.java b/forge-game/src/main/java/forge/trackable/TrackableProperty.java index 12ef4f8e4d9..e1934dfe06a 100644 --- a/forge-game/src/main/java/forge/trackable/TrackableProperty.java +++ b/forge-game/src/main/java/forge/trackable/TrackableProperty.java @@ -275,6 +275,7 @@ public enum TrackableProperty { Ability(TrackableTypes.BooleanType), OptionalTrigger(TrackableTypes.BooleanType), OptionalCosts(TrackableTypes.StringType), + ApiType(TrackableTypes.StringType), //Combat AttackersWithDefenders(TrackableTypes.GenericMapType, FreezeMode.IgnoresFreeze), 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 085be1394f9..67f99c9f32b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -173,38 +173,41 @@ public void setYieldMode(PlayerView player, final YieldMode mode) { playerYieldMode.put(player, mode); GameView gameView = callback.getGameView(); + // Use network-safe GameView properties instead of gameView.getGame() + // This ensures proper operation for non-host players in multiplayer + if (gameView == null) { + return; + } + + forge.game.phase.PhaseType phase = gameView.getPhase(); + int currentTurn = gameView.getTurn(); + PlayerView currentPlayerTurn = gameView.getPlayerTurn(); + // Track current phase for UNTIL_NEXT_PHASE mode - if (mode == YieldMode.UNTIL_NEXT_PHASE && gameView != null && gameView.getGame() != null) { - forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); - yieldNextPhaseStartPhase.put(player, ph.getPhase()); + if (mode == YieldMode.UNTIL_NEXT_PHASE) { + yieldNextPhaseStartPhase.put(player, phase); } // Track turn number for UNTIL_END_OF_TURN mode - if (mode == YieldMode.UNTIL_END_OF_TURN && gameView != null && gameView.getGame() != null) { - yieldStartTurn.put(player, gameView.getGame().getPhaseHandler().getTurn()); + if (mode == YieldMode.UNTIL_END_OF_TURN) { + yieldStartTurn.put(player, currentTurn); } // Track turn and phase state for UNTIL_BEFORE_COMBAT mode - if (mode == YieldMode.UNTIL_BEFORE_COMBAT && gameView != null && gameView.getGame() != null) { - forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); - yieldCombatStartTurn.put(player, ph.getTurn()); - forge.game.phase.PhaseType phase = ph.getPhase(); + if (mode == YieldMode.UNTIL_BEFORE_COMBAT) { + yieldCombatStartTurn.put(player, currentTurn); boolean atOrAfterCombat = phase != null && (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); } // Track turn and phase state for UNTIL_END_STEP mode - if (mode == YieldMode.UNTIL_END_STEP && gameView != null && gameView.getGame() != null) { - forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); - yieldEndStepStartTurn.put(player, ph.getTurn()); - forge.game.phase.PhaseType phase = ph.getPhase(); + if (mode == YieldMode.UNTIL_END_STEP) { + yieldEndStepStartTurn.put(player, currentTurn); boolean atOrAfterEndStep = phase != null && (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); } // Track if UNTIL_YOUR_NEXT_TURN was started during our turn - if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN && gameView != null && gameView.getGame() != null) { - forge.game.phase.PhaseHandler ph = gameView.getGame().getPhaseHandler(); - forge.game.player.Player playerObj = gameView.getGame().getPlayer(player); - boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN) { + boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); } } @@ -244,6 +247,7 @@ public YieldMode getYieldMode(PlayerView player) { /** * Check if auto-yield should be active for a player based on current game state. + * Uses network-safe GameView properties to work correctly for non-host players in multiplayer. */ public boolean shouldAutoYieldForPlayer(PlayerView player) { player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance @@ -268,17 +272,18 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { } GameView gameView = callback.getGameView(); - if (gameView == null || gameView.getGame() == null) { + if (gameView == null) { return false; } - forge.game.Game game = gameView.getGame(); - forge.game.phase.PhaseHandler ph = game.getPhaseHandler(); + // Use network-safe GameView properties instead of gameView.getGame() + forge.game.phase.PhaseType currentPhase = gameView.getPhase(); + int currentTurn = gameView.getTurn(); + PlayerView currentPlayerTurn = gameView.getPlayerTurn(); return switch (mode) { case UNTIL_NEXT_PHASE -> { forge.game.phase.PhaseType startPhase = yieldNextPhaseStartPhase.get(player); - forge.game.phase.PhaseType currentPhase = ph.getPhase(); if (startPhase == null) { yieldNextPhaseStartPhase.put(player, currentPhase); yield true; @@ -290,7 +295,8 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { yield true; } case UNTIL_STACK_CLEARS -> { - boolean stackEmpty = game.getStack().isEmpty() && !game.getStack().hasSimultaneousStackEntries(); + // Use GameView.getStack() which is network-synchronized + boolean stackEmpty = gameView.getStack() == null || gameView.getStack().isEmpty(); if (stackEmpty) { clearYieldMode(player); yield false; @@ -300,7 +306,6 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { case UNTIL_END_OF_TURN -> { // Yield until end of the turn when yield was set - clear when turn number changes Integer startTurn = yieldStartTurn.get(player); - int currentTurn = ph.getTurn(); if (startTurn == null) { // Turn wasn't tracked when yield was set - track it now yieldStartTurn.put(player, currentTurn); @@ -313,9 +318,8 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { yield true; } case UNTIL_YOUR_NEXT_TURN -> { - // Yield until our turn starts - forge.game.player.Player playerObj = game.getPlayer(player); - boolean isOurTurn = ph.getPlayerTurn().equals(playerObj); + // Yield until our turn starts - use PlayerView comparison (network-safe) + boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); Boolean startedDuringOurTurn = yieldYourTurnStartedDuringOurTurn.get(player); if (startedDuringOurTurn == null) { @@ -342,16 +346,14 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { yield true; } case UNTIL_BEFORE_COMBAT -> { - forge.game.phase.PhaseType phase = ph.getPhase(); Integer startTurn = yieldCombatStartTurn.get(player); Boolean startedAtOrAfterCombat = yieldCombatStartedAtOrAfterCombat.get(player); - int currentTurn = ph.getTurn(); if (startTurn == null) { // Tracking wasn't set - initialize it now yieldCombatStartTurn.put(player, currentTurn); - boolean atOrAfterCombat = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + boolean atOrAfterCombat = currentPhase != null && + (currentPhase == forge.game.phase.PhaseType.COMBAT_BEGIN || currentPhase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); startTurn = currentTurn; startedAtOrAfterCombat = atOrAfterCombat; @@ -359,8 +361,8 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { // Check if we should stop: we're at or past combat on a DIFFERENT turn than when we started, // OR we're at combat on the SAME turn but we started BEFORE combat - boolean atOrAfterCombatNow = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + boolean atOrAfterCombatNow = currentPhase != null && + (currentPhase == forge.game.phase.PhaseType.COMBAT_BEGIN || currentPhase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); if (atOrAfterCombatNow) { boolean differentTurn = currentTurn > startTurn; @@ -374,16 +376,14 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { yield true; } case UNTIL_END_STEP -> { - forge.game.phase.PhaseType phase = ph.getPhase(); Integer startTurn = yieldEndStepStartTurn.get(player); Boolean startedAtOrAfterEndStep = yieldEndStepStartedAtOrAfterEndStep.get(player); - int currentTurn = ph.getTurn(); if (startTurn == null) { // Tracking wasn't set - initialize it now yieldEndStepStartTurn.put(player, currentTurn); - boolean atOrAfterEndStep = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + boolean atOrAfterEndStep = currentPhase != null && + (currentPhase == forge.game.phase.PhaseType.END_OF_TURN || currentPhase == forge.game.phase.PhaseType.CLEANUP); yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); startTurn = currentTurn; startedAtOrAfterEndStep = atOrAfterEndStep; @@ -391,8 +391,8 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { // Check if we should stop: we're at or past end step on a DIFFERENT turn than when we started, // OR we're at end step on the SAME turn but we started BEFORE end step - boolean atOrAfterEndStepNow = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + boolean atOrAfterEndStepNow = currentPhase != null && + (currentPhase == forge.game.phase.PhaseType.END_OF_TURN || currentPhase == forge.game.phase.PhaseType.CLEANUP); if (atOrAfterEndStepNow) { boolean differentTurn = currentTurn > startTurn; @@ -411,25 +411,23 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { /** * Check if yield should be interrupted based on game conditions. + * Uses network-safe GameView properties to work correctly for non-host players in multiplayer. */ private boolean shouldInterruptYield(final PlayerView player) { GameView gameView = callback.getGameView(); - if (gameView == null || gameView.getGame() == null) { + if (gameView == null) { return false; } - forge.game.Game game = gameView.getGame(); - forge.game.player.Player p = game.getPlayer(player); - if (p == null) { - return false; // Can't determine player, don't interrupt - } ForgePreferences prefs = FModel.getPreferences(); - forge.game.phase.PhaseType phase = game.getPhaseHandler().getPhase(); + forge.game.phase.PhaseType phase = gameView.getPhase(); + PlayerView currentPlayerTurn = gameView.getPlayerTurn(); + forge.game.combat.CombatView combatView = gameView.getCombat(); if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && - game.getCombat() != null && isBeingAttacked(game, p)) { + combatView != null && isBeingAttacked(combatView, player)) { return true; } } @@ -437,24 +435,31 @@ private boolean shouldInterruptYield(final PlayerView player) { if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_BLOCKERS)) { // Only interrupt if there are creatures attacking THIS player or their planeswalkers/battles if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_BLOCKERS && - game.getCombat() != null && isBeingAttacked(game, p)) { + combatView != null && isBeingAttacked(combatView, player)) { return true; } } if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TARGETING)) { - for (forge.game.spellability.StackItemView si : gameView.getStack()) { - if (targetsPlayerOrPermanents(si, p)) { - return true; + forge.util.collect.FCollectionView stack = gameView.getStack(); + if (stack != null) { + for (forge.game.spellability.StackItemView si : stack) { + if (targetsPlayerOrPermanents(si, player)) { + return true; + } } } } if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { - if (!game.getStack().isEmpty()) { - forge.game.spellability.SpellAbility topSa = game.getStack().peekAbility(); - // Exclude triggered abilities - if they target you, the "targeting" setting handles that - if (topSa != null && !topSa.isTrigger() && !topSa.getActivatingPlayer().equals(p)) { + // Use network-safe stack access via GameView + forge.game.spellability.StackItemView topItem = gameView.peekStack(); + if (topItem != null) { + PlayerView activatingPlayer = topItem.getActivatingPlayer(); + boolean isOpponent = activatingPlayer != null && !activatingPlayer.equals(player); + + // Interrupt for any opponent spell/ability that targets player or their permanents + if (isOpponent && targetsPlayerOrPermanents(topItem, player)) { return true; } } @@ -464,14 +469,16 @@ private boolean shouldInterruptYield(final PlayerView player) { if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { YieldMode mode = playerYieldMode.get(player); // Don't interrupt UNTIL_END_OF_TURN on our own turn - if (!(mode == YieldMode.UNTIL_END_OF_TURN && game.getPhaseHandler().getPlayerTurn().equals(p))) { + boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); + if (!(mode == YieldMode.UNTIL_END_OF_TURN && isOurTurn)) { return true; } } } if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { - if (hasMassRemovalOnStack(game, p)) { + // Use network-safe StackItemView.getApiType() for mass removal detection + if (hasMassRemovalOnStack(gameView, player)) { return true; } } @@ -481,24 +488,29 @@ private boolean shouldInterruptYield(final PlayerView player) { /** * Check if the player is being attacked (directly or via planeswalkers/battles). + * Uses network-safe CombatView instead of Combat. */ - private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p) { - forge.game.combat.Combat combat = game.getCombat(); - if (combat == null) { + private boolean isBeingAttacked(forge.game.combat.CombatView combatView, PlayerView player) { + if (combatView == null) { return false; } - // Check if player is being attacked directly - if (!combat.getAttackersOf(p).isEmpty()) { + // Check if player is being attacked directly (player as defender) + forge.util.collect.FCollection attackersOfPlayer = combatView.getAttackersOf(player); + if (attackersOfPlayer != null && !attackersOfPlayer.isEmpty()) { return true; } // Check if any planeswalkers or battles controlled by the player are being attacked - for (forge.game.GameEntity defender : combat.getDefenders()) { - if (defender instanceof forge.game.card.Card) { - forge.game.card.Card card = (forge.game.card.Card) defender; - if (card.getController().equals(p) && !combat.getAttackersOf(defender).isEmpty()) { - return true; + for (forge.game.GameEntityView defender : combatView.getDefenders()) { + if (defender instanceof CardView) { + CardView cardDefender = (CardView) defender; + PlayerView controller = cardDefender.getController(); + if (controller != null && controller.equals(player)) { + forge.util.collect.FCollection attackers = combatView.getAttackersOf(defender); + if (attackers != null && !attackers.isEmpty()) { + return true; + } } } } @@ -510,23 +522,28 @@ private boolean isBeingAttacked(forge.game.Game game, forge.game.player.Player p * Check if a stack item targets the player or their permanents. * Recursively checks sub-instances to handle abilities with targeting in sub-abilities * (e.g., Oona, Queen of the Fae whose targeting is in a sub-ability). + * Uses network-safe PlayerView comparisons. */ - private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, forge.game.player.Player p) { - PlayerView pv = p.getView(); - - for (PlayerView target : si.getTargetPlayers()) { - if (target.equals(pv)) return true; + private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView si, PlayerView player) { + forge.util.collect.FCollectionView targetPlayers = si.getTargetPlayers(); + if (targetPlayers != null) { + for (PlayerView target : targetPlayers) { + if (target.equals(player)) return true; + } } - for (CardView target : si.getTargetCards()) { - if (target.getController() != null && target.getController().equals(pv)) { - return true; + forge.util.collect.FCollectionView targetCards = si.getTargetCards(); + if (targetCards != null) { + for (CardView target : targetCards) { + if (target.getController() != null && target.getController().equals(player)) { + return true; + } } } // Recursively check sub-instances for targeting (handles abilities like Oona) forge.game.spellability.StackItemView subInstance = si.getSubInstance(); - if (subInstance != null && targetsPlayerOrPermanents(subInstance, p)) { + if (subInstance != null && targetsPlayerOrPermanents(subInstance, player)) { return true; } @@ -535,24 +552,25 @@ private boolean targetsPlayerOrPermanents(forge.game.spellability.StackItemView /** * Check if there's a mass removal spell on the stack that could affect the player's permanents. - * Only interrupts if the spell was cast by an opponent AND the player has permanents that match. + * Uses network-safe StackItemView.getApiType() for detection. + * Only interrupts if the spell was cast by an opponent. */ - private boolean hasMassRemovalOnStack(forge.game.Game game, forge.game.player.Player p) { - if (game.getStack().isEmpty()) { + private boolean hasMassRemovalOnStack(GameView gameView, PlayerView player) { + forge.util.collect.FCollectionView stack = gameView.getStack(); + if (stack == null || stack.isEmpty()) { return false; } - for (forge.game.spellability.SpellAbilityStackInstance si : game.getStack()) { - forge.game.spellability.SpellAbility sa = si.getSpellAbility(); - if (sa == null) continue; + for (forge.game.spellability.StackItemView si : stack) { + PlayerView activatingPlayer = si.getActivatingPlayer(); // Only interrupt for opponent's spells - if (sa.getActivatingPlayer() == null || sa.getActivatingPlayer().equals(p)) { + if (activatingPlayer == null || activatingPlayer.equals(player)) { continue; } - // Check if this is a mass removal spell type - if (isMassRemovalSpell(sa, game, p)) { + // Check if this is a mass removal spell type (including sub-instances) + if (isMassRemovalStackItem(si)) { return true; } } @@ -560,97 +578,40 @@ private boolean hasMassRemovalOnStack(forge.game.Game game, forge.game.player.Pl } /** - * Determine if a spell ability is a mass removal effect that could affect the player. + * Determine if a stack item is a mass removal effect. + * Recursively checks sub-instances for modal spells like Farewell. */ - private boolean isMassRemovalSpell(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { - forge.game.ability.ApiType api = sa.getApi(); - if (api == null) { - return false; + private boolean isMassRemovalStackItem(forge.game.spellability.StackItemView si) { + // Check the main ability + if (isMassRemovalApiType(si.getApiType())) { + return true; } - // Check the main ability and all sub-abilities (for modal spells like Farewell) - forge.game.spellability.SpellAbility current = sa; - while (current != null) { - if (checkSingleAbilityForMassRemoval(current, game, p)) { - return true; - } - current = current.getSubAbility(); + // Check sub-instances for modal spells like Farewell + forge.game.spellability.StackItemView subInstance = si.getSubInstance(); + if (subInstance != null && isMassRemovalStackItem(subInstance)) { + return true; } return false; } /** - * Check if a single ability (not including sub-abilities) is mass removal affecting the player. + * Check if an API type name represents a mass removal effect. */ - private boolean checkSingleAbilityForMassRemoval(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p) { - forge.game.ability.ApiType api = sa.getApi(); - if (api == null) { + private boolean isMassRemovalApiType(String apiType) { + if (apiType == null) { return false; } - String apiName = api.name(); - // DestroyAll - Wrath of God, Day of Judgment, Damnation - if ("DestroyAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - - // ChangeZoneAll with Destination=Exile or Graveyard - Farewell, Merciless Eviction - if ("ChangeZoneAll".equals(apiName)) { - String destination = sa.getParam("Destination"); - if ("Exile".equals(destination) || "Graveyard".equals(destination)) { - // Check Origin - only care about Battlefield - String origin = sa.getParam("Origin"); - if (origin != null && origin.contains("Battlefield")) { - return playerHasMatchingPermanents(sa, game, p, "ChangeType"); - } - } - } - // DamageAll - Blasphemous Act, Chain Reaction - if ("DamageAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - // SacrificeAll - All Is Dust, Bane of Progress - if ("SacrificeAll".equals(apiName)) { - return playerHasMatchingPermanents(sa, game, p, "ValidCards"); - } - - return false; - } - - /** - * Check if the player has any permanents that match the spell's filter parameter. - */ - private boolean playerHasMatchingPermanents(forge.game.spellability.SpellAbility sa, forge.game.Game game, forge.game.player.Player p, String filterParam) { - String validFilter = sa.getParam(filterParam); - - // Get all permanents controlled by the player - forge.game.card.CardCollectionView playerPermanents = p.getCardsIn(forge.game.zone.ZoneType.Battlefield); - if (playerPermanents.isEmpty()) { - return false; // No permanents = no reason to interrupt - } - - // If no filter specified, assume it affects all permanents - if (validFilter == null || validFilter.isEmpty()) { - return true; - } - - // Check if any of the player's permanents match the filter - for (forge.game.card.Card card : playerPermanents) { - try { - if (card.isValid(validFilter.split(","), sa.getActivatingPlayer(), sa.getHostCard(), sa)) { - return true; - } - } catch (Exception e) { - // If validation fails, be conservative and assume it might affect us - return true; - } - } - - return false; + // ChangeZoneAll - Farewell, Merciless Eviction (covers exile/bounce effects) + return "DestroyAll".equals(apiType) || + "DamageAll".equals(apiType) || + "SacrificeAll".equals(apiType) || + "ChangeZoneAll".equals(apiType); } /** @@ -662,23 +623,27 @@ private boolean isYieldExperimentalEnabled() { /** * Get the total number of players in the game. + * Uses network-safe GameView.getPlayers() instead of Game.getPlayers(). */ public int getPlayerCount() { GameView gameView = callback.getGameView(); - return gameView != null && gameView.getGame() != null - ? gameView.getGame().getPlayers().size() - : 0; + if (gameView == null) { + return 0; + } + forge.util.collect.FCollectionView players = gameView.getPlayers(); + return players != null ? players.size() : 0; } /** * Mark a suggestion as declined for the current turn. + * Uses network-safe GameView.getTurn() instead of Game.getPhaseHandler().getTurn(). */ public void declineSuggestion(PlayerView player, String suggestionType) { player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance GameView gameView = callback.getGameView(); - if (gameView == null || gameView.getGame() == null) return; + if (gameView == null) return; - int currentTurn = gameView.getGame().getPhaseHandler().getTurn(); + int currentTurn = gameView.getTurn(); Integer storedTurn = declinedSuggestionsTurn.get(player); // Reset if turn changed @@ -692,13 +657,14 @@ public void declineSuggestion(PlayerView player, String suggestionType) { /** * Check if a suggestion has been declined for the current turn. + * Uses network-safe GameView.getTurn() instead of Game.getPhaseHandler().getTurn(). */ public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance GameView gameView = callback.getGameView(); - if (gameView == null || gameView.getGame() == null) return false; + if (gameView == null) return false; - int currentTurn = gameView.getGame().getPhaseHandler().getTurn(); + int currentTurn = gameView.getTurn(); Integer storedTurn = declinedSuggestionsTurn.get(player); if (storedTurn == null || storedTurn != currentTurn) { From 1f9e92e711db054a168bd97a487e3d81072b8c77 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 10:18:48 +1030 Subject: [PATCH 18/33] Network-safe smart suggestions and yield button fixes - Add HasAvailableActions and WillLoseManaAtEndOfPhase TrackableProperties - Refactor InputPassPriority to use PlayerView/GameView instead of transient Game - Fix suggestions appearing immediately after yield ends (yieldJustEnded tracking) - Fix wrong yield mode when clicking yield buttons (legacy set interference) - Add PlayerView lookup to autoPassUntilEndOfTurn/autoPassCancel methods Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 72 ++++++++++ .../java/forge/game/phase/PhaseHandler.java | 6 + .../main/java/forge/game/player/Player.java | 29 ++++ .../java/forge/game/player/PlayerView.java | 16 +++ .../forge/trackable/TrackableProperty.java | 2 + .../gamemodes/match/AbstractGuiGame.java | 5 + .../gamemodes/match/YieldController.java | 30 +++- .../match/input/InputPassPriority.java | 134 +++++++++++------- .../java/forge/gui/interfaces/IGuiGame.java | 2 + 9 files changed, 241 insertions(+), 55 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1d2eec798f6..29e344869ed 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -649,6 +649,78 @@ SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 ## Changelog +### 2026-01-31 - Network-Safe GameView Refactor + +**Problem:** Non-host players in multiplayer experienced freezing and yield malfunctions. The yield system was using `gameView.getGame()` which returns a transient `Game` object that is not serialized over the network. For non-host clients, this returned a dummy local `Game` instance with no actual state. + +**Solution:** Comprehensive refactoring of all network-unsafe code in both `YieldController` and `InputPassPriority` to use network-synchronized TrackableProperties and View classes exclusively. + +**Core Changes:** + +| Component | Before | After | +|-----------|--------|-------| +| Phase tracking | `game.getPhaseHandler().getPhase()` | `gameView.getPhase()` | +| Turn tracking | `game.getPhaseHandler().getTurn()` | `gameView.getTurn()` | +| Current player | `game.getPhaseHandler().getPlayerTurn()` | `gameView.getPlayerTurn()` | +| Stack access | `game.getStack()` | `gameView.getStack()` | +| Combat access | `game.getCombat()` | `gameView.getCombat()` | +| Player lookup | `game.getPlayer(playerView)` | Direct `PlayerView` comparison | +| Player actions check | `player.getCardsIn().getAllPossibleAbilities()` | `playerView.hasAvailableActions()` | +| Mana loss check | `player.getManaPool().willManaBeLostAtEndOfPhase()` | `playerView.willLoseManaAtEndOfPhase()` | +| Mana availability | `player.getManaPool().totalMana()` | `playerView.getMana()` + battlefield scan | +| Hand contents | `player.getCardsIn(ZoneType.Hand)` | `playerView.getHand()` | +| Battlefield | `player.getCardsIn(ZoneType.Battlefield)` | `playerView.getBattlefield()` | + +**New TrackableProperties:** +- `TrackableProperty.HasAvailableActions` - Whether player has playable spells/abilities +- `TrackableProperty.WillLoseManaAtEndOfPhase` - Whether floating mana will be lost +- `TrackableProperty.ApiType` - Spell API type for mass removal detection + +**New PlayerView Methods:** +- `hasAvailableActions()` - Network-safe check for available actions +- `willLoseManaAtEndOfPhase()` - Network-safe mana loss warning + +**New Player Methods:** +- `hasAvailableActions()` - Checks hand and battlefield for playable abilities +- `updateAvailableActionsForView()` - Updates the view property + +**Update Call Sites:** +- `Player.updateManaForView()` - Now also updates `WillLoseManaAtEndOfPhase` +- `PhaseHandler.passPriority()` - Now updates `HasAvailableActions` for priority player + +**InputPassPriority Refactoring:** +- `getGameView()` / `getPlayerView()` - New helper methods for view access +- `getDefaultYieldMode()` - Now uses `gameView.getPlayers().size()` +- `shouldShowStackYieldPrompt()` - Uses `gameView.getStack()` and `playerView.hasAvailableActions()` +- `shouldShowNoManaPrompt()` - Uses `gameView.getStack()`, `gameView.getPlayerTurn()`, `playerView.getHand()`, `hasManaAvailable(PlayerView)` +- `hasManaAvailable(PlayerView)` - Replaced `Player` version with view-based implementation +- `shouldShowNoActionsPrompt()` - Uses view properties exclusively +- `passPriority()` - Uses `playerView.willLoseManaAtEndOfPhase()` for mana warning + +**YieldController Refactoring:** +- `setYieldMode()` - Phase/turn tracking now uses GameView +- `shouldAutoYieldForPlayer()` - All yield termination checks use GameView +- `shouldInterruptYield()` - Uses CombatView, StackItemView, PlayerView +- `isBeingAttacked()` - Refactored to use CombatView instead of Combat +- `targetsPlayerOrPermanents()` - Uses PlayerView directly +- `hasMassRemovalOnStack()` - Uses StackItemView.getApiType() +- `getPlayerCount()` - Uses gameView.getPlayers() +- `declineSuggestion()` / `isSuggestionDeclined()` - Uses gameView.getTurn() + +**Bug Fix - Suggestions appearing after yield ends:** +- **Problem:** Smart suggestions (e.g., "no mana available") would appear immediately after a yield ended, even though the player had just been yielding. This occurred because `shouldAutoYieldForPlayer()` would clear the yield mode before `showMessage()` ran, so `isAlreadyYielding()` returned false. +- **Solution:** Added `yieldJustEnded` tracking set in YieldController. When a yield ends due to an end condition or interrupt, the player is added to this set. `InputPassPriority.showMessage()` now checks `didYieldJustEnd()` (which clears the flag) and skips suggestions if true. +- **Files:** `YieldController.java`, `IGuiGame.java`, `AbstractGuiGame.java`, `InputPassPriority.java` + +**Bug Fix - Wrong yield mode active after clicking yield button:** +- **Problem:** On network clients, clicking a yield button (e.g., "Combat") would highlight correctly but the actual behavior would be UNTIL_END_OF_TURN instead of the selected mode. This was caused by two issues: + 1. The legacy `autoPassUntilEndOfTurn` set wasn't being cleared when setting an experimental yield mode + 2. The `autoPassUntilEndOfTurn()` and `autoPassCancel()` methods were missing the PlayerView lookup, causing set membership mismatches +- **Solution:** + 1. Added `autoPassUntilEndOfTurn.remove(player)` at the start of `setYieldMode()` when experimental yields are enabled + 2. Added `TrackableTypes.PlayerViewType.lookup(player)` to `autoPassUntilEndOfTurn()` and `autoPassCancel()` methods +- **Files:** `YieldController.java` + ### Initial Implementation - YieldController Architecture **Core Design:** diff --git a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java index 6070635ae3d..710150c700f 100644 --- a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java +++ b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java @@ -1163,6 +1163,12 @@ else if (!game.getStack().hasSimultaneousStackEntries()) { for (final Player p : game.getPlayers()) { p.setHasPriority(getPriorityPlayer() == p); } + + // Update available actions for the player receiving priority (for network-safe yield suggestions) + Player priorityPlayer = getPriorityPlayer(); + if (priorityPlayer != null) { + priorityPlayer.updateAvailableActionsForView(); + } } private boolean checkStateBasedEffects() { diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index d1d83371f36..a9a0da93f50 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -1759,6 +1759,35 @@ public final ManaPool getManaPool() { } public void updateManaForView() { view.updateMana(this); + view.updateWillLoseManaAtEndOfPhase(this); + } + + /** + * Check if this player has any available actions (playable spells/abilities). + * Used for smart yield suggestions in network play. + */ + public boolean hasAvailableActions() { + // Check hand for playable spells + for (Card card : getCardsIn(ZoneType.Hand)) { + if (!card.getAllPossibleAbilities(this, true).isEmpty()) { + return true; + } + } + + // Check battlefield for non-mana activated abilities + for (Card card : getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllPossibleAbilities(this, true)) { + if (!sa.isManaAbility()) { + return true; + } + } + } + + return false; + } + + public void updateAvailableActionsForView() { + view.updateHasAvailableActions(this); } public final int getNumPowerSurgeLands() { 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 fda59ebb712..cf3798ad974 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -556,6 +556,22 @@ void updateMana(Player p) { set(TrackableProperty.Mana, mana); } + public boolean hasAvailableActions() { + Boolean val = get(TrackableProperty.HasAvailableActions); + return val != null && val; + } + void updateHasAvailableActions(Player p) { + set(TrackableProperty.HasAvailableActions, p.hasAvailableActions()); + } + + public boolean willLoseManaAtEndOfPhase() { + Boolean val = get(TrackableProperty.WillLoseManaAtEndOfPhase); + return val != null && val; + } + void updateWillLoseManaAtEndOfPhase(Player p) { + set(TrackableProperty.WillLoseManaAtEndOfPhase, p.getManaPool().willManaBeLostAtEndOfPhase()); + } + private List getDetailsList() { final List details = Lists.newArrayListWithCapacity(8); details.add(Localizer.getInstance().getMessage("lblLifeHas", String.valueOf(getLife()))); diff --git a/forge-game/src/main/java/forge/trackable/TrackableProperty.java b/forge-game/src/main/java/forge/trackable/TrackableProperty.java index e1934dfe06a..182b60f36b5 100644 --- a/forge-game/src/main/java/forge/trackable/TrackableProperty.java +++ b/forge-game/src/main/java/forge/trackable/TrackableProperty.java @@ -245,6 +245,8 @@ public enum TrackableProperty { HasDelirium(TrackableTypes.BooleanType), AvatarLifeDifference(TrackableTypes.IntegerType, FreezeMode.IgnoresFreeze), HasLost(TrackableTypes.BooleanType), + HasAvailableActions(TrackableTypes.BooleanType), + WillLoseManaAtEndOfPhase(TrackableTypes.BooleanType), //SpellAbility HostCard(TrackableTypes.CardViewType), 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 87cbffdbe17..bab656f49b7 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -540,6 +540,11 @@ public final YieldMode getYieldMode(PlayerView player) { return getYieldController().getYieldMode(player); } + @Override + public final boolean didYieldJustEnd(PlayerView player) { + return getYieldController().didYieldJustEnd(player); + } + @Override public final boolean shouldAutoYieldForPlayer(PlayerView player) { return getYieldController().shouldAutoYieldForPlayer(player); 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 67f99c9f32b..023c5ed6cc3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -74,6 +74,9 @@ public interface YieldCallback { private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); private final Map declinedSuggestionsTurn = Maps.newHashMap(); + // Track when yield just ended this priority (to suppress suggestions) + private final Set yieldJustEnded = Sets.newHashSet(); + /** * Create a new YieldController with the given callback for GUI updates. * @param callback the callback interface for GUI operations @@ -86,14 +89,16 @@ public YieldController(YieldCallback callback) { * Automatically pass priority until reaching the Cleanup phase of the current turn. * This is the legacy auto-pass behavior. */ - public void autoPassUntilEndOfTurn(final PlayerView player) { + public void autoPassUntilEndOfTurn(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure consistent PlayerView instance autoPassUntilEndOfTurn.add(player); } /** * Cancel auto-pass for the given player. */ - public void autoPassCancel(final PlayerView player) { + public void autoPassCancel(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure consistent PlayerView instance if (!autoPassUntilEndOfTurn.remove(player)) { return; } @@ -170,6 +175,10 @@ public void setYieldMode(PlayerView player, final YieldMode mode) { return; } + // Clear any legacy auto-pass state to prevent interference + // (legacy check in shouldAutoYieldForPlayer runs first and would override experimental mode) + autoPassUntilEndOfTurn.remove(player); + playerYieldMode.put(player, mode); GameView gameView = callback.getGameView(); @@ -245,6 +254,16 @@ public YieldMode getYieldMode(PlayerView player) { return mode != null ? mode : YieldMode.NONE; } + /** + * Check if the player's yield just ended this priority pass (due to end condition or interrupt). + * Used to suppress smart suggestions immediately after a yield ends. + * This method clears the flag after checking. + */ + public boolean didYieldJustEnd(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + return yieldJustEnded.remove(player); + } + /** * Check if auto-yield should be active for a player based on current game state. * Uses network-safe GameView properties to work correctly for non-host players in multiplayer. @@ -268,6 +287,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { // Check interrupt conditions if (shouldInterruptYield(player)) { clearYieldMode(player); + yieldJustEnded.add(player); // Track that yield just ended return false; } @@ -290,6 +310,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { } if (currentPhase != startPhase) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } yield true; @@ -299,6 +320,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { boolean stackEmpty = gameView.getStack() == null || gameView.getStack().isEmpty(); if (stackEmpty) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } yield true; @@ -313,6 +335,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { } if (currentTurn > startTurn) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } yield true; @@ -334,6 +357,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { // If we started during opponent's turn, stop when we reach our turn if (!Boolean.TRUE.equals(startedDuringOurTurn)) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } } else { @@ -370,6 +394,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { if (differentTurn || sameTurnButStartedBeforeCombat) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } } @@ -400,6 +425,7 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { if (differentTurn || sameTurnButStartedBeforeEndStep) { clearYieldMode(player); + yieldJustEnded.add(player); yield false; } } 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 d41712cb721..60776c05be2 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 @@ -17,11 +17,16 @@ */ package forge.gamemodes.match.input; +import forge.card.mana.ManaAtom; import forge.game.Game; +import forge.game.GameView; import forge.game.card.Card; +import forge.game.card.CardView; 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.game.zone.ZoneType; import forge.gamemodes.match.YieldMode; import forge.localinstance.properties.ForgePreferences; @@ -32,6 +37,7 @@ import forge.util.ITriggerEvent; import forge.util.Localizer; import forge.util.ThreadUtil; +import forge.util.collect.FCollectionView; import java.util.ArrayList; import java.util.List; @@ -63,8 +69,10 @@ public InputPassPriority(final PlayerControllerHuman controller) { @Override public final void showMessage() { // Check if experimental yield features are enabled and show smart suggestions - // Only show suggestions if not already yielding - if (isExperimentalYieldEnabled() && !isAlreadyYielding()) { + // Only show suggestions if not already yielding and yield didn't just end + // (suppresses suggestions immediately after a yield expires or is interrupted) + if (isExperimentalYieldEnabled() && !isAlreadyYielding() + && !getController().getGui().didYieldJustEnd(getOwner())) { ForgePreferences prefs = FModel.getPreferences(); Localizer loc = Localizer.getInstance(); @@ -104,6 +112,16 @@ && shouldShowNoActionsPrompt() } private void showYieldSuggestionPrompt() { + // Double-check yield state right before showing - it may have been set + // between the initial check and now (e.g., async button click in multiplayer) + if (isAlreadyYielding()) { + pendingSuggestion = null; + pendingSuggestionType = null; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + Localizer loc = Localizer.getInstance(); String fullMessage = pendingSuggestionMessage + "\n" + loc.getMessage("lblYieldSuggestionDeclineHint"); showMessage(fullMessage); @@ -208,16 +226,25 @@ protected boolean allowAwaitNextInput() { private void passPriority(final Runnable runnable) { if (FModel.getPreferences().getPrefBoolean(FPref.UI_MANA_LOST_PROMPT)) { //if gui player has mana floating that will be lost if phase ended right now, prompt before passing priority - final Game game = getController().getGame(); - if (game.getStack().isEmpty()) { //phase can't end right now if stack isn't empty - Player player = game.getPhaseHandler().getPriorityPlayer(); - if (player != null && player.getManaPool().willManaBeLostAtEndOfPhase() && player.getLobbyPlayer() == GamePlayerUtil.getGuiPlayer()) { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv != null && pv != null) { + FCollectionView stack = gv.getStack(); + if ((stack == null || stack.isEmpty()) && + pv.willLoseManaAtEndOfPhase() && + pv.isLobbyPlayer(GamePlayerUtil.getGuiPlayer())) { //must invoke in game thread so dialog can be shown on mobile game ThreadUtil.invokeInGameThread(() -> { Localizer localizer = Localizer.getInstance(); String message = localizer.getMessage("lblYouHaveManaFloatingInYourManaPoolCouldBeLostIfPassPriority"); - if (player.getManaPool().hasBurn()) { - message += " " + localizer.getMessage("lblYouWillTakeManaBurnDamageEqualAmountFloatingManaLostThisWay"); + // Note: hasBurn check still needs the transient Game access for now + // This is acceptable as the mana burn message is just supplementary info + final Game game = getController().getGame(); + if (game != null) { + Player player = game.getPhaseHandler().getPriorityPlayer(); + if (player != null && player.getManaPool().hasBurn()) { + message += " " + localizer.getMessage("lblYouWillTakeManaBurnDamageEqualAmountFloatingManaLostThisWay"); + } } if (getController().getGui().showConfirmDialog(message, localizer.getMessage("lblManaFloating"), localizer.getMessage("lblOK"), localizer.getMessage("lblCancel"))) { runnable.run(); @@ -295,73 +322,70 @@ private boolean isExperimentalYieldEnabled() { return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); } + private GameView getGameView() { + return getController().getGui().getGameView(); + } + + private PlayerView getPlayerView() { + return getController().getPlayer().getView(); + } + private YieldMode getDefaultYieldMode() { - return getController().getGame().getPlayers().size() >= 3 + GameView gv = getGameView(); + return gv != null && gv.getPlayers().size() >= 3 ? YieldMode.UNTIL_YOUR_NEXT_TURN : YieldMode.UNTIL_END_OF_TURN; } private boolean shouldShowStackYieldPrompt() { - Game game = getController().getGame(); - Player player = getController().getPlayer(); + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; - if (game.getStack().isEmpty()) { + FCollectionView stack = gv.getStack(); + if (stack == null || stack.isEmpty()) { return false; } - return !canRespondToStack(game, player); - } - - private boolean canRespondToStack(Game game, Player player) { - // Check hand for playable spells (getAllPossibleAbilities already filters by timing) - for (Card card : player.getCardsIn(ZoneType.Hand)) { - if (!card.getAllPossibleAbilities(player, true).isEmpty()) { - return true; - } - } - - // Check battlefield for activatable abilities (excluding mana abilities) - for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (!sa.isManaAbility()) { - return true; - } - } - } - - return false; + // Use TrackableProperty - player has no available actions + return !pv.hasAvailableActions(); } private boolean shouldShowNoManaPrompt() { - Game game = getController().getGame(); - Player player = getController().getPlayer(); + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; - if (!game.getStack().isEmpty()) { + FCollectionView stack = gv.getStack(); + if (stack != null && !stack.isEmpty()) { return false; } - if (game.getPhaseHandler().getPlayerTurn().equals(player)) { + PlayerView currentTurn = gv.getPlayerTurn(); + if (currentTurn != null && currentTurn.equals(pv)) { return false; } - if (player.getCardsIn(ZoneType.Hand).isEmpty()) { + FCollectionView hand = pv.getHand(); + if (hand == null || hand.isEmpty()) { return false; } - return !hasManaAvailable(player); + return !hasManaAvailable(pv); } - private boolean hasManaAvailable(Player player) { - if (player.getManaPool().totalMana() > 0) { - return true; + private boolean hasManaAvailable(PlayerView pv) { + // Check floating mana + for (byte manaType : ManaAtom.MANATYPES) { + if (pv.getMana(manaType) > 0) return true; } - for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - if (card.isUntapped()) { - for (SpellAbility sa : card.getManaAbilities()) { - if (sa.canPlay()) { - return true; - } + // Check for untapped lands (simplified check using view data) + FCollectionView battlefield = pv.getBattlefield(); + if (battlefield != null) { + for (CardView cv : battlefield) { + if (!cv.isTapped() && cv.getCurrentState().isLand()) { + return true; } } } @@ -370,17 +394,21 @@ private boolean hasManaAvailable(Player player) { } private boolean shouldShowNoActionsPrompt() { - Player player = getController().getPlayer(); - Game game = getController().getGame(); + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; - if (!game.getStack().isEmpty()) { + FCollectionView stack = gv.getStack(); + if (stack != null && !stack.isEmpty()) { return false; } - if (game.getPhaseHandler().getPlayerTurn().equals(player)) { + PlayerView currentTurn = gv.getPlayerTurn(); + if (currentTurn != null && currentTurn.equals(pv)) { return false; } - return !canRespondToStack(game, player); + // Use TrackableProperty + return !pv.hasAvailableActions(); } } diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 885e0b33270..c5f1833114a 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -271,6 +271,8 @@ public interface IGuiGame { YieldMode getYieldMode(PlayerView player); + boolean didYieldJustEnd(PlayerView player); + int getPlayerCount(); // Smart suggestion decline tracking From c21416eedc6ff5dacf84d1d03f67c0d508849a84 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 13:03:12 +1030 Subject: [PATCH 19/33] Fix yield sync recursion and smart suggestion mana checking - Add setYieldModeSilent() to break infinite recursion when syncing yield state between server and client (stack overflow fix) - Fix getPlayerView() in InputPassPriority to use network-synchronized PlayerView from GameView instead of local Player object - Enhance hasAvailableActions() to actually check mana availability using heuristic (CostPartMana.canPay() always returns true) - Smart suggestions now correctly trigger when player has cards but can't afford any spells Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 36 +++++++++ .../main/java/forge/game/player/Player.java | 39 ++++++++-- .../gamemodes/match/AbstractGuiGame.java | 76 +++++++++++++++++++ .../gamemodes/match/YieldController.java | 49 +++++++++++- .../match/input/InputPassPriority.java | 20 ++++- .../forge/gamemodes/net/ProtocolMethod.java | 6 +- .../net/client/NetGameController.java | 6 ++ .../gamemodes/net/server/NetGuiGame.java | 6 ++ .../java/forge/gui/interfaces/IGuiGame.java | 18 +++++ .../forge/interfaces/IGameController.java | 10 +++ .../forge/player/PlayerControllerHuman.java | 13 ++++ 11 files changed, 267 insertions(+), 12 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 29e344869ed..60342d7c2e9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -721,6 +721,42 @@ SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 2. Added `TrackableTypes.PlayerViewType.lookup(player)` to `autoPassUntilEndOfTurn()` and `autoPassCancel()` methods - **Files:** `YieldController.java` +**Bug Fix - Yield mode not working on network clients:** +- **Problem:** Network clients could set yield mode locally (button highlighted correctly), but the server didn't know about it. When priority passed back to the client, the server would check yield state on its own `NetGuiGame` instance which had no knowledge of the client's yield settings, resulting in smart suggestions being shown despite yielding. +- **Root Cause:** Yield state was stored client-side only. The client's `CMatchUI.setYieldMode()` updated its local `YieldController`, but the server's `NetGuiGame` (which handles priority logic for remote players) had its own separate `YieldController` that was never updated. +- **Solution:** Added network protocol support for yield mode synchronization: + 1. Added `notifyYieldModeChanged(PlayerView, YieldMode)` to `IGameController` interface with default no-op implementation + 2. Added `notifyYieldModeChanged` to `ProtocolMethod` enum (CLIENT -> SERVER) + 3. Implemented in `NetGameController` to send yield changes to server + 4. Implemented in `PlayerControllerHuman` to receive and update server's GUI state + 5. Added `setYieldModeFromRemote()` to `IGuiGame`/`AbstractGuiGame` to update yield without triggering notification loop + 6. Modified `AbstractGuiGame.setYieldMode()` to call `notifyYieldModeChanged()` on the game controller +- **Files:** `IGameController.java`, `ProtocolMethod.java`, `NetGameController.java`, `PlayerControllerHuman.java`, `IGuiGame.java`, `AbstractGuiGame.java` + +**Bug Fix - Yield button stays highlighted after yield ends on network client:** +- **Problem:** When a yield mode ended due to its end condition (e.g., "yield until next turn" expires when turn changes), the yield button on the client remained highlighted even though the yield had stopped. +- **Root Cause:** The server's YieldController detected the end condition and cleared the yield mode, but this wasn't synchronized back to the client. The client's local YieldController still thought the yield was active, keeping the button highlighted. +- **Solution:** Added server→client yield state synchronization: + 1. Added `syncYieldMode` to `ProtocolMethod` enum (SERVER -> CLIENT) + 2. Added `syncYieldMode(PlayerView, YieldMode)` to `IGuiGame` interface + 3. Implemented in `NetGuiGame` to send yield state to client + 4. Implemented in `AbstractGuiGame` to receive and update local state + 5. Added `syncYieldModeToClient` to `YieldCallback` interface + 6. Modified `YieldController.clearYieldMode()` to call the callback, notifying the client +- **Files:** `ProtocolMethod.java`, `IGuiGame.java`, `NetGuiGame.java`, `AbstractGuiGame.java`, `YieldController.java` + +**Bug Fix - Wrong prompt shown after setting yield on network client:** +- **Problem:** Client set "End Step" yield (button correctly highlighted in red), but prompt showed "Yielding until end of turn" text. +- **Root Cause:** When client set yield mode, `AbstractGuiGame.setYieldMode()` showed the correct prompt locally, then notified the server. The server's `setYieldModeFromRemote()` was calling `updateAutoPassPrompt()` which sent another prompt back to the client, overwriting the correct one. Due to timing or state differences, the server sent the wrong message. +- **Solution:** Removed `updateAutoPassPrompt()` call from `setYieldModeFromRemote()` since the client already showed the correct prompt when it set the yield mode locally. +- **Files:** `AbstractGuiGame.java` + +**Bug Fix - Network PlayerView tracker mismatch causing yield lookup failures:** +- **Problem:** Yield mode set by client wasn't being found when server checked `mayAutoPass()`. +- **Root Cause:** Network-deserialized PlayerViews have a different `Tracker` instance than the server's PlayerViews. When `notifyYieldModeChanged` stored the yield mode using the network PlayerView's tracker, the `TrackableTypes.PlayerViewType.lookup()` later failed because the server's `mayAutoPass()` used a different PlayerView instance with a different tracker. +- **Solution:** Added `lookupPlayerViewById()` helper method that finds the matching PlayerView from `GameView.getPlayers()` by ID comparison, ensuring yield mode is stored against the server's canonical PlayerView instance. +- **Files:** `AbstractGuiGame.java` + ### Initial Implementation - YieldController Architecture **Core Design:** diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index a9a0da93f50..075c389ae7a 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -1765,20 +1765,49 @@ public void updateManaForView() { /** * Check if this player has any available actions (playable spells/abilities). * Used for smart yield suggestions in network play. + * + * Note: This uses a heuristic for mana checking since CostPartMana.canPay() + * always returns true. We estimate available mana from floating mana plus + * untapped mana sources and compare to spell CMCs. */ public boolean hasAvailableActions() { - // Check hand for playable spells + // Estimate available mana: floating mana + untapped mana-producing permanents + int availableMana = getManaPool().totalMana(); + for (Card card : getCardsIn(ZoneType.Battlefield)) { + if (!card.isTapped() && !card.getManaAbilities().isEmpty()) { + // Count each untapped mana source as ~1 mana (simplified estimate) + availableMana++; + } + } + + // Check hand for playable spells that we can afford for (Card card : getCardsIn(ZoneType.Hand)) { - if (!card.getAllPossibleAbilities(this, true).isEmpty()) { - return true; + for (SpellAbility sa : card.getAllPossibleAbilities(this, true)) { + // Check if this is a spell we could potentially afford + if (sa.isSpell()) { + int cmc = sa.getPayCosts().getTotalMana().getCMC(); + if (cmc <= availableMana) { + return true; + } + } else if (sa.isLandAbility()) { + // Land abilities are already filtered by canPlay() for timing + return true; + } } } - // Check battlefield for non-mana activated abilities + // Check battlefield for non-mana activated abilities we can afford for (Card card : getCardsIn(ZoneType.Battlefield)) { for (SpellAbility sa : card.getAllPossibleAbilities(this, true)) { if (!sa.isManaAbility()) { - return true; + // Check if we can afford the activation cost + int activationCost = 0; + if (sa.getPayCosts() != null && sa.getPayCosts().hasManaCost()) { + activationCost = sa.getPayCosts().getTotalMana().getCMC(); + } + if (activationCost <= availableMana) { + return true; + } } } } 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 bab656f49b7..7d027e525d5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -440,6 +440,11 @@ public void cancelAwaitNextInput() { public GameView getGameView() { return AbstractGuiGame.this.getGameView(); } + @Override + public void syncYieldModeToClient(PlayerView player, YieldMode mode) { + // Sync yield state to network client (for server->client updates) + AbstractGuiGame.this.syncYieldMode(player, mode); + } }); } return yieldController; @@ -528,6 +533,77 @@ public final void updateAutoPassPrompt() { public final void setYieldMode(PlayerView player, final YieldMode mode) { getYieldController().setYieldMode(player, mode); updateAutoPassPrompt(); + + // Notify remote server if this is a network client + IGameController controller = getGameController(player); + if (controller != null) { + controller.notifyYieldModeChanged(player, mode); + } + } + + @Override + public final void setYieldModeFromRemote(PlayerView player, final YieldMode mode) { + // Update yield state without triggering notification (to avoid loops) + // Used when server receives yield state from network client + // Note: Don't call updateAutoPassPrompt() here - the client already showed + // the correct prompt when it set the yield mode locally + + // The PlayerView from network has a different tracker than server's PlayerViews. + // We need to find the matching PlayerView from the GameView using ID comparison. + player = lookupPlayerViewById(player); + if (player == null) { + return; // Player not found in game + } + getYieldController().setYieldMode(player, mode); + } + + /** + * Look up a PlayerView by ID from the current GameView's player list. + * Used for network play where deserialized PlayerViews have different trackers. + */ + private PlayerView lookupPlayerViewById(PlayerView networkPlayer) { + if (networkPlayer == null) { + return null; + } + GameView gv = getGameView(); + if (gv == null) { + return networkPlayer; // Fall back to using the network instance + } + int playerId = networkPlayer.getId(); + for (PlayerView pv : gv.getPlayers()) { + if (pv.getId() == playerId) { + return pv; + } + } + return networkPlayer; // Fall back if not found + } + + @Override + public final void clearYieldModeFromRemote(PlayerView player) { + // Clear yield state from remote client without triggering notification + // Look up the correct PlayerView instance by ID (network PlayerViews have different trackers) + player = lookupPlayerViewById(player); + if (player == null) { + return; + } + getYieldController().clearYieldMode(player); + } + + @Override + public void syncYieldMode(PlayerView player, YieldMode mode) { + // Receive yield state sync from server (when server clears yield due to end condition) + // Look up the correct PlayerView instance by ID (network PlayerViews have different trackers) + player = lookupPlayerViewById(player); + if (player == null) { + return; + } + // Use silent methods to avoid triggering callback which would loop back here + if (mode == null || mode == YieldMode.NONE) { + getYieldController().clearYieldModeSilent(player); + } else { + getYieldController().setYieldModeSilent(player, mode); + } + // Note: Don't call updateAutoPassPrompt() - server already sent the correct prompt } @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 023c5ed6cc3..e787ce1abc1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -53,6 +53,11 @@ public interface YieldCallback { void awaitNextInput(); void cancelAwaitNextInput(); GameView getGameView(); + /** + * Sync yield mode to network client. + * Called when yield mode is cleared due to end condition. + */ + void syncYieldModeToClient(PlayerView player, YieldMode mode); } private final YieldCallback callback; @@ -226,6 +231,46 @@ public void setYieldMode(PlayerView player, final YieldMode mode) { */ public void clearYieldMode(PlayerView player) { player = TrackableTypes.PlayerViewType.lookup(player); // ensure we use the correct player instance + clearYieldModeInternal(player); + + callback.showPromptMessage(player, ""); + callback.updateButtons(player, false, false, false); + callback.awaitNextInput(); + + // Notify client to update its local yield state (for network play) + callback.syncYieldModeToClient(player, YieldMode.NONE); + } + + /** + * Clear yield mode silently without triggering callbacks. + * Used when receiving sync from server to avoid recursive loops. + */ + public void clearYieldModeSilent(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + clearYieldModeInternal(player); + } + + /** + * Set yield mode silently without triggering callbacks. + * Used when receiving sync from server to avoid recursive loops. + * Only sets the mode itself - server manages the detailed tracking state. + */ + public void setYieldModeSilent(PlayerView player, YieldMode mode) { + player = TrackableTypes.PlayerViewType.lookup(player); + if (mode == null || mode == YieldMode.NONE) { + clearYieldModeInternal(player); + return; + } + // Clear legacy auto-pass to prevent interference + autoPassUntilEndOfTurn.remove(player); + // Just set the mode - detailed tracking is managed by server + playerYieldMode.put(player, mode); + } + + /** + * Internal method to clear yield state without callbacks. + */ + private void clearYieldModeInternal(PlayerView player) { playerYieldMode.remove(player); yieldStartTurn.remove(player); yieldCombatStartTurn.remove(player); @@ -235,10 +280,6 @@ public void clearYieldMode(PlayerView player) { yieldYourTurnStartedDuringOurTurn.remove(player); yieldNextPhaseStartPhase.remove(player); autoPassUntilEndOfTurn.remove(player); // Legacy compatibility - - callback.showPromptMessage(player, ""); - callback.updateButtons(player, false, false, false); - callback.awaitNextInput(); } /** 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 60776c05be2..23854b473c7 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 @@ -27,7 +27,6 @@ import forge.game.player.actions.PassPriorityAction; import forge.game.spellability.SpellAbility; import forge.game.spellability.StackItemView; -import forge.game.zone.ZoneType; import forge.gamemodes.match.YieldMode; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; @@ -327,7 +326,24 @@ private GameView getGameView() { } private PlayerView getPlayerView() { - return getController().getPlayer().getView(); + // For network clients, we need to get the PlayerView from the GameView + // because that's where the synchronized TrackableProperty values are. + // The local Player's view won't have the network-updated properties. + GameView gv = getGameView(); + if (gv == null) { + return getController().getPlayer().getView(); + } + PlayerView owner = getOwner(); + if (owner == null) { + return null; + } + // Look up the matching PlayerView from GameView to get network-synchronized state + for (PlayerView pv : gv.getPlayers()) { + if (pv.getId() == owner.getId()) { + return pv; + } + } + return owner; // Fallback to local if not found } private YieldMode getDefaultYieldMode() { 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 fb16741142e..3988d0e0a61 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -9,6 +9,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMode; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; import forge.interfaces.IGameController; @@ -79,6 +80,8 @@ public enum ProtocolMethod { isUiSetToSkipPhase (Mode.SERVER, Boolean.TYPE, PlayerView.class, PhaseType.class), setRememberedActions(Mode.SERVER, Void.TYPE), nextRememberedAction(Mode.SERVER, Void.TYPE), + // Server->Client yield state sync (when server clears yield due to end condition) + syncYieldMode (Mode.SERVER, Void.TYPE, PlayerView.class, YieldMode.class), // Client -> Server // Note: these should all return void, to avoid awkward situations in @@ -97,7 +100,8 @@ public enum ProtocolMethod { getActivateDescription (Mode.CLIENT, String.class, CardView.class), concede (Mode.CLIENT, Void.TYPE), alphaStrike (Mode.CLIENT, Void.TYPE), - reorderHand (Mode.CLIENT, Void.TYPE, CardView.class, Integer.TYPE); + reorderHand (Mode.CLIENT, Void.TYPE, CardView.class, Integer.TYPE), + notifyYieldModeChanged (Mode.CLIENT, Void.TYPE, PlayerView.class, YieldMode.class); private enum Mode { SERVER(IGuiGame.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 57bec3d0aee..fdb794d0b82 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 @@ -5,6 +5,7 @@ import forge.game.player.actions.PlayerAction; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMode; import forge.gamemodes.net.GameProtocolSender; import forge.gamemodes.net.ProtocolMethod; import forge.interfaces.IDevModeCheats; @@ -155,4 +156,9 @@ public String playbackText() { return null; } } + + @Override + public void notifyYieldModeChanged(PlayerView player, YieldMode mode) { + send(ProtocolMethod.notifyYieldModeChanged, player, mode); + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java index 0a0c7b54963..c15c586dd71 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java @@ -314,6 +314,12 @@ public boolean isUiSetToSkipPhase(final PlayerView playerTurn, final PhaseType p return sendAndWait(ProtocolMethod.isUiSetToSkipPhase, playerTurn, phase); } + @Override + public void syncYieldMode(final PlayerView player, final forge.gamemodes.match.YieldMode mode) { + // Send yield state to client (when server clears yield due to end condition) + send(ProtocolMethod.syncYieldMode, player, mode); + } + @Override protected void updateCurrentPlayer(final PlayerView player) { // TODO Auto-generated method stub diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index c5f1833114a..feb5dd77595 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -265,6 +265,24 @@ public interface IGuiGame { // Extended yield mode methods (experimental feature) void setYieldMode(PlayerView player, YieldMode mode); + /** + * Update yield mode from remote client without triggering notification. + * Used by server to receive yield state from network clients. + */ + void setYieldModeFromRemote(PlayerView player, YieldMode mode); + + /** + * Clear yield mode from remote client without triggering notification. + * Used by server to receive yield state from network clients. + */ + void clearYieldModeFromRemote(PlayerView player); + + /** + * Sync yield mode from server to client. + * Used when server clears yield (end condition met) and needs to update client UI. + */ + void syncYieldMode(PlayerView player, YieldMode mode); + void clearYieldMode(PlayerView player); boolean shouldAutoYieldForPlayer(PlayerView player); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 4367ca77bb2..1653377a6c1 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -6,6 +6,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMode; import forge.util.ITriggerEvent; public interface IGameController { @@ -45,4 +46,13 @@ public interface IGameController { String getActivateDescription(CardView card); void reorderHand(CardView card, int index); + + /** + * Notify the server that the client's yield mode has changed. + * Used for network play to sync yield state from client to server. + * Default implementation does nothing (for local/host games). + */ + default void notifyYieldModeChanged(PlayerView player, YieldMode mode) { + // Default: no-op for local games + } } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 4baab246ecb..ba0934ee829 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3319,6 +3319,19 @@ public void reorderHand(final CardView card, final int index) { player.updateZoneForView(hand); } + @Override + public void notifyYieldModeChanged(final PlayerView playerView, final forge.gamemodes.match.YieldMode mode) { + // Update the server's GUI with the client's yield mode + // This syncs yield state from network client to server + // Uses FromRemote methods to avoid triggering another notification and to handle + // PlayerView tracker mismatch (network PlayerViews have different trackers than server's) + if (mode == null) { + getGui().clearYieldModeFromRemote(playerView); + } else { + getGui().setYieldModeFromRemote(playerView, mode); + } + } + @Override public String chooseCardName(SpellAbility sa, List faces, String message) { ICardFace face = chooseSingleCardFace(sa, faces, message); From 6ddc7f6787c006071124625c08b09c517af2a93c Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 18:40:53 +1030 Subject: [PATCH 20/33] Fix yield panel disappearing on layout refresh and improve 2-player layout - Add populate() calls to SLayoutIO.openLayout() and revertLayout() after loading completes, following the pattern used in FControl.setCurrentScreen() - This fixes yield panel not reappearing after View > Refresh Layout or Layout > Open operations - Incidentally fixes the same issue for other dynamic panels (dev mode, etc.) - Add stale parent cell detection in VMatchUI to handle layout refresh - In 2-player games, move Clear Stack button to middle position since Your Turn button is not shown (only relevant for 3+ player games) Co-Authored-By: Claude Opus 4.5 --- .../main/java/forge/gui/framework/SLayoutIO.java | 10 ++++++++++ .../main/java/forge/screens/match/VMatchUI.java | 14 +++++++++----- .../forge/screens/match/controllers/CYield.java | 8 ++++++++ .../java/forge/screens/match/views/VYield.java | 11 ++++++++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/SLayoutIO.java b/forge-gui-desktop/src/main/java/forge/gui/framework/SLayoutIO.java index 733467c610f..50c05ec9a4d 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/SLayoutIO.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/SLayoutIO.java @@ -78,6 +78,11 @@ public static void openLayout() { FThreads.invokeInEdtLater(() -> { SLayoutIO.loadLayout(loadFile); SLayoutIO.saveLayout(null); + // Repopulate the current screen to handle dynamic panels (yield, dev mode, etc.) + FScreen currentScreen = Singletons.getControl().getCurrentScreen(); + if (currentScreen != null) { + currentScreen.getView().populate(); + } SOverlayUtils.hideOverlay(); }); } @@ -89,6 +94,11 @@ public static void revertLayout() { FThreads.invokeInEdtLater(() -> { SLayoutIO.loadLayout(null); + // Repopulate the current screen to handle dynamic panels (yield, dev mode, etc.) + FScreen currentScreen = Singletons.getControl().getCurrentScreen(); + if (currentScreen != null) { + currentScreen.getView().populate(); + } SOverlayUtils.hideOverlay(); }); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java index d760b101f89..cc5043ca09a 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VMatchUI.java @@ -78,11 +78,15 @@ public void populate() { parent.setSelected(parent.getDocs().get(0)); } } - } else if (vYield.getParentCell() == null) { - // Yield enabled but not in layout - add to stack cell by default - final DragCell stackCell = EDocID.REPORT_STACK.getDoc().getParentCell(); - if (stackCell != null) { - stackCell.addDoc(vYield); + } else if (vYield.getParentCell() == null || + !FView.SINGLETON_INSTANCE.getDragCells().contains(vYield.getParentCell())) { + // Yield enabled but not in any cell or has stale reference - add to prompt cell by default + DragCell promptCell = EDocID.REPORT_MESSAGE.getDoc().getParentCell(); + if (promptCell == null) { + promptCell = EDocID.REPORT_LOG.getDoc().getParentCell(); + } + if (promptCell != null) { + promptCell.addDoc(vYield); } } 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 index 8fcf209e94d..1ed1523036f 100644 --- 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 @@ -65,6 +65,14 @@ public final VYield getView() { return view; } + /** + * Returns true if this is a multiplayer game (3+ players). + * Used by VYield to adjust layout for the "Your Turn" button. + */ + public boolean isMultiplayer() { + return isMultiplayer; + } + @Override public void register() { } 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 index 6bf72a9b343..64f1df287c5 100644 --- 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 @@ -144,10 +144,15 @@ public void populate() { container.add(btnCombat, buttonConstraints); container.add(btnEndStep, buttonConstraints); - // Row 2: End Turn, Your Turn, Clear Stack + // Row 2: End Turn, [Your Turn if multiplayer], Clear Stack container.add(btnEndTurn, buttonConstraints); - container.add(btnYourTurn, buttonConstraints); - container.add(btnClearStack, buttonConstraints); + if (controller.isMultiplayer()) { + container.add(btnYourTurn, buttonConstraints); + container.add(btnClearStack, buttonConstraints); + } else { + // In 2-player games, Clear Stack moves to middle position + container.add(btnClearStack, buttonConstraints); + } } @Override From 4b8c1b12db43842533b7a46f1233e1844b636119 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 19:51:32 +1030 Subject: [PATCH 21/33] Add Expanded Yield Options wiki documentation Create user-facing documentation for the experimental yield system: - Yield modes and their end conditions - Access methods (panel, right-click menu, keyboard shortcuts) - Smart yield suggestions - Configurable interrupt conditions - Troubleshooting guide Co-Authored-By: Claude Opus 4.5 --- docs/Expanded-Yield-Options.md | 110 +++++++++++++++++++++++++++++++++ docs/_sidebar.md | 1 + 2 files changed, 111 insertions(+) create mode 100644 docs/Expanded-Yield-Options.md diff --git a/docs/Expanded-Yield-Options.md b/docs/Expanded-Yield-Options.md new file mode 100644 index 00000000000..e4c4d4215a0 --- /dev/null +++ b/docs/Expanded-Yield-Options.md @@ -0,0 +1,110 @@ +# Expanded Yield Options + +The standard priority system in Forge can involve dozens of priority passes every turn. This can cause frustration, particularly in multiplayer Magic games like Commander, where one player's delay responding to priority can slow down the game for everybody else. + +**Expanded Yield Options** is an experimental feature that significantly expands the legacy Forge auto-pass system through: + +- giving players the ability to automatically yield priority until specific game conditions are met, without needing to respond to priority passes in the meantime. +- configurable yield interrupt conditions, so you'll always get control back when something important happens (e.g. you are attacked or targeted by a spell). +- smart suggestions for you 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 in-game menu, and can be set up to suit your own gameplay preferences. + + +**Note:** This feature is disabled by default and must be explicitly enabled in preferences. + +## How to Enable: + +1. In the Forge main menu open Gameplay Settings > Preferences. +2. Under the Gameplay section, click **Experimental: Enable expanded yield options**. +4. Restart the game to take effect. + +## Once enabled: +- **Yield Options** will appear as a dockable panel inside the match UI (by default this is a tab in the same panel as prompt). This panel can be re-arranged within the layout at your convenience. +- The Yield Options submenu appears in: Forge > Game > Yield Options. +- Keyboard shortcuts for different yield modes become active. +- Smart suggestions begin appearing in the prompt area (if enabled). + +## Yield Modes + +The Yield Options panel and keyboard shortcuts provide the following yield modes: + +| Mode | Description | Ends When | Default Hotkey | +|------|-------------|-----------|----------------| +| **Next Phase** | Auto-pass until phase changes | Any phase transition | F1 | +| **Until Combat** | Auto-pass until combat begins | Next COMBAT_BEGIN phase | F2 | +| **Until End Step** | Auto-pass until end step | Next END_OF_TURN phase | F3 | +| **Until Next Turn** | Auto-pass until next turn | Turn number changes | F4 | +| **Until Your Next Turn** | Auto-pass until you become active player | Your turn starts (3+ player games only) | F5 | +| **Until Stack Clears** | Auto-pass while stack has items | Stack becomes empty | F6 | + +If you engage a yield mode, the button for that mode will be highlighted in the Yield Options panel to signify the yield is active. The prompt area will also describe what event you are yielding to. + +A yield can be cancelled at any time by pressing the ESC key. You will then be given priority passes as normal. + +Yield buttons are disabled during pre-game, mulligan and cleanup/discard phases. + +If enabled in the Yield Options menu, you can also right-click the "End Turn" button in the prompt area to select yield options. + +All keyboard shortcuts above can be configured in the Preferences menu. + +## Interrupt Conditions + +Yield modes automatically cancel when important game events occur. Each interrupt can be individually configured in Forge > Game > Yield Options > Interrupt Settings. + +| Interrupt | Default | Description | +|-----------|---------|---------------------------------------------------------------------------------------| +| **Attackers declared against you** | ON | Triggers when creatures attack you specifically (not when other players are attacked) | +| **You can declare blockers** | ON | Triggers when creatures are attacking you | +| **You or your permanents targeted** | ON | Triggers when a spell/ability targets you or something you control | +| **Mass removal spell cast** | ON | Triggers when opponent casts a board wipe or mass removal spell. | +| **Opponent casts any spell** | OFF | Triggers on spells and activated abilities (not triggered abilities) | +| **Combat begins** | OFF | Triggers at start of any combat phase | +| **Cards revealed or choices made** | OFF | Triggers when opponent reveal dialogs and choices are made. | + +**Multiplayer Note:** Attack and blocker interrupts are scoped to you specifically. If Player A attacks Player B, your yield will NOT be interrupted. + +## Smart Yield Suggestions + +When enabled, the system detects situations where you likely cannot take action and prompts you with a yield suggestion. Suggestions appear in the prompt area with Accept/Decline buttons. + +| Suggestion | When It Appears | Suggested Mode | +|------------|-----------------|----------------| +| **Cannot respond to stack** | You have no instant-speed responses available | Until Stack Clears | +| **No mana available** | You have cards but no untapped mana sources (not your turn) | Default yield mode | +| **No actions available** | No playable cards or activatable abilities (not your turn, stack empty) | Default yield mode | + +**Suggestion Behavior:** +- Each suggestion type can be individually enabled/disabled in preferences +- Suggestions will not appear if you're already yielding +- Declining a suggestion suppresses that kind of suggestion until the next turn (i.e. this stops you repeatedly recieving the same prompt). +- Clicking a yield button while a suggestion is showing activates the clicked yield mode instead of the suggested one. + + + +## Troubleshooting + +### Yield doesn't activate when clicking button +- Verify **Experimental Yield Options** is set to `true` in preferences +- Restart Forge after changing the preference +- Yield buttons are disabled during mulligan, pre-game, and cleanup phases + +### Yield clears unexpectedly +- Check interrupt settings in Forge > Game > Yield Options > Interrupt Settings +- If being attacked or targeted, yield will clear (if those interrupts are enabled) +- Yield modes automatically clear when their end condition is met + +### Smart suggestions not appearing +- Verify individual suggestion preferences are enabled +- Suggestions don't appear if you're already yielding +- If you declined a suggestion, it won't appear again until next turn +- Suggestions only appear when experimental yields are enabled + +### Network play notes +- All players (host and clients) must have enabled Expanded Yield Options for the system to work in network multiplayer. +- Each client manages its own yield state - yield preferences are not synchronized. +- Yield state cannot cause desync; the network layer only sees standard priority pass messages. + +## Bugs and suggestions? + +Please feel free to provide feedback and bug reports in the Discord. \ No newline at end of file diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 0848e7bd6d9..207f84dd825 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -8,6 +8,7 @@ - [Network FAQ](Network-FAQ.md) - [Network Extra](Networking-Extras.md) - [Advanced search](Advanced-Search.md) + - [Expanded Yield Options](Expanded-Yield-Options.md) - Adventure Mode From 98f458bb5e5d31b9d30b7119286d67f27646160b Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 31 Jan 2026 20:34:03 +1030 Subject: [PATCH 22/33] Refactor PlayerView lookup to reuse existing method Move lookupPlayerViewById to IGuiGame interface and reuse in InputPassPriority instead of duplicating the ID-based lookup logic. Co-Authored-By: Claude Opus 4.5 --- .../gamemodes/match/AbstractGuiGame.java | 7 ++----- .../match/input/InputPassPriority.java | 19 +------------------ .../java/forge/gui/interfaces/IGuiGame.java | 8 ++++++++ 3 files changed, 11 insertions(+), 23 deletions(-) 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 7d027e525d5..7263240956e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -557,11 +557,8 @@ public final void setYieldModeFromRemote(PlayerView player, final YieldMode mode getYieldController().setYieldMode(player, mode); } - /** - * Look up a PlayerView by ID from the current GameView's player list. - * Used for network play where deserialized PlayerViews have different trackers. - */ - private PlayerView lookupPlayerViewById(PlayerView networkPlayer) { + @Override + public PlayerView lookupPlayerViewById(PlayerView networkPlayer) { if (networkPlayer == null) { return null; } 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 23854b473c7..7049219aef7 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 @@ -326,24 +326,7 @@ private GameView getGameView() { } private PlayerView getPlayerView() { - // For network clients, we need to get the PlayerView from the GameView - // because that's where the synchronized TrackableProperty values are. - // The local Player's view won't have the network-updated properties. - GameView gv = getGameView(); - if (gv == null) { - return getController().getPlayer().getView(); - } - PlayerView owner = getOwner(); - if (owner == null) { - return null; - } - // Look up the matching PlayerView from GameView to get network-synchronized state - for (PlayerView pv : gv.getPlayers()) { - if (pv.getId() == owner.getId()) { - return pv; - } - } - return owner; // Fallback to local if not found + return getController().getGui().lookupPlayerViewById(getOwner()); } private YieldMode getDefaultYieldMode() { diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index feb5dd77595..7a7ddcb2941 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -315,4 +315,12 @@ public interface IGuiGame { void clearAutoYields(); void setCurrentPlayer(PlayerView player); + + /** + * Look up a PlayerView by ID from the current GameView's player list. + * Used for network play where deserialized PlayerViews have different trackers. + * @param player the PlayerView to look up (uses its ID for matching) + * @return the matching PlayerView from GameView, or the input player if not found + */ + PlayerView lookupPlayerViewById(PlayerView player); } From b7442b05bbd8d7b28c0d7cb07b5e98ef3365eb8b Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 07:49:42 +1030 Subject: [PATCH 23/33] Shift yield hotkeys from F1-F6 to F2-F7 to avoid F1=Help conflict Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 23 ++++++++++--------- docs/Expanded-Yield-Options.md | 14 +++++------ .../properties/ForgePreferences.java | 12 +++++----- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 60342d7c2e9..2e41a1f102f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -49,13 +49,13 @@ Extended yield options that allow players to automatically pass priority until s 2. **Right-Click Menu**: Right-click the "End Turn" button to see yield options (configurable) -3. **Keyboard Shortcuts** (F-keys to avoid conflict with ability selection 1-9): - - `F1` - Yield until next phase - - `F2` - Yield until before combat - - `F3` - Yield until end step - - `F4` - Yield until next turn - - `F5` - Yield until your next turn (3+ players) - - `F6` - Yield until stack clears +3. **Keyboard Shortcuts** (F2-F7 to avoid conflict with F1=Help): + - `F2` - Yield until next phase + - `F3` - Yield until before combat + - `F4` - Yield until end step + - `F5` - Yield until next turn + - `F6` - Yield until your next turn (3+ players) + - `F7` - Yield until stack clears - `ESC` - Cancel active yield ### Smart Yield Suggestions @@ -873,12 +873,13 @@ SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 2. **UNTIL_END_STEP mode** - Yield until the END_OF_TURN or CLEANUP phase. Useful for end-of-turn effects. -3. **F-key hotkeys** - Updated hotkey scheme to avoid conflicts with ability selection (1-9): - - F1: Yield until end of turn - - F2: Yield until stack clears +3. **F-key hotkeys** - Updated hotkey scheme (F2-F7 to avoid conflict with F1=Help): + - F2: Yield until next phase - F3: Yield until before combat - F4: Yield until end step - - F5: Yield until your next turn + - F5: Yield until end of turn + - F6: Yield until your next turn + - F7: Yield until stack clears - ESC: Cancel active yield **Bug Fixes:** diff --git a/docs/Expanded-Yield-Options.md b/docs/Expanded-Yield-Options.md index e4c4d4215a0..229113a5ba7 100644 --- a/docs/Expanded-Yield-Options.md +++ b/docs/Expanded-Yield-Options.md @@ -29,14 +29,14 @@ These features are highly configurable through the in-game menu, and can be set The Yield Options panel and keyboard shortcuts provide the following yield modes: -| Mode | Description | Ends When | Default Hotkey | +| Mode | Description | Ends When | Default Hotkey | |------|-------------|-----------|----------------| -| **Next Phase** | Auto-pass until phase changes | Any phase transition | F1 | -| **Until Combat** | Auto-pass until combat begins | Next COMBAT_BEGIN phase | F2 | -| **Until End Step** | Auto-pass until end step | Next END_OF_TURN phase | F3 | -| **Until Next Turn** | Auto-pass until next turn | Turn number changes | F4 | -| **Until Your Next Turn** | Auto-pass until you become active player | Your turn starts (3+ player games only) | F5 | -| **Until Stack Clears** | Auto-pass while stack has items | Stack becomes empty | F6 | +| **Next Phase** | Auto-pass until phase changes | Any phase transition | F2 | +| **Until Combat** | Auto-pass until combat begins | Next COMBAT_BEGIN phase | F3 | +| **Until End Step** | Auto-pass until end step | Next END_OF_TURN phase | F4 | +| **Until Next Turn** | Auto-pass until next turn | Turn number changes | F5 | +| **Until Your Next Turn** | Auto-pass until you become active player | Your turn starts (3+ player games only) | F6 | +| **Until Stack Clears** | Auto-pass while stack has items | Stack becomes empty | F7 | If you engage a yield mode, the button for that mode will be highlighted in the Yield Options panel to signify the yield is active. The prompt area will also describe what event you are yielding to. 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 3e6c5d99856..cff2f5897ec 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -301,12 +301,12 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), - SHORTCUT_YIELD_UNTIL_NEXT_PHASE("112"), // F1 key - SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("113"), // F2 key - SHORTCUT_YIELD_UNTIL_END_STEP("114"), // F3 key - SHORTCUT_YIELD_UNTIL_END_OF_TURN("115"), // F4 key - SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116"), // F5 key - SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117"), // F6 key + SHORTCUT_YIELD_UNTIL_NEXT_PHASE("113"), // F2 key + SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("114"), // F3 key + SHORTCUT_YIELD_UNTIL_END_STEP("115"), // F4 key + SHORTCUT_YIELD_UNTIL_END_OF_TURN("116"), // F5 key + SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("117"), // F6 key + SHORTCUT_YIELD_UNTIL_STACK_CLEARS("118"), // F7 key SHORTCUT_YIELD_CANCEL("27"), // ESC key LAST_IMPORTED_CUBE_ID(""); From 47947e3d386f9067a8081a4e5cba6a01c7f8ef84 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 07:59:29 +1030 Subject: [PATCH 24/33] Disable smart yield suggestions on mobile GUI Mobile GUI (Libgdx) doesn't support the yield panel, so check isLibgdxPort() before enabling experimental yield features. Co-Authored-By: Claude Opus 4.5 --- .../java/forge/gamemodes/match/input/InputPassPriority.java | 5 +++++ 1 file changed, 5 insertions(+) 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 7049219aef7..2a63e9ac22d 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 @@ -28,6 +28,7 @@ import forge.game.spellability.SpellAbility; import forge.game.spellability.StackItemView; import forge.gamemodes.match.YieldMode; +import forge.gui.GuiBase; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; @@ -318,6 +319,10 @@ public boolean selectAbility(final SpellAbility ab) { // Smart yield suggestion helper methods private boolean isExperimentalYieldEnabled() { + // Smart suggestions are desktop-only (mobile GUI doesn't support yield panel) + if (GuiBase.getInterface().isLibgdxPort()) { + return false; + } return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); } From b5861660b7c8ca2c4b446a744e436258d82f925d Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 08:48:53 +1030 Subject: [PATCH 25/33] Add toggle behavior for yield buttons Clicking an already-active (highlighted) yield button now deactivates that yield mode, giving users an intuitive way to cancel auto-yield without using the ESC key. Co-Authored-By: Claude Opus 4.5 --- docs/Expanded-Yield-Options.md | 2 +- .../screens/match/controllers/CYield.java | 66 +++++-------------- 2 files changed, 19 insertions(+), 49 deletions(-) diff --git a/docs/Expanded-Yield-Options.md b/docs/Expanded-Yield-Options.md index 229113a5ba7..81f3fb25a05 100644 --- a/docs/Expanded-Yield-Options.md +++ b/docs/Expanded-Yield-Options.md @@ -40,7 +40,7 @@ The Yield Options panel and keyboard shortcuts provide the following yield modes If you engage a yield mode, the button for that mode will be highlighted in the Yield Options panel to signify the yield is active. The prompt area will also describe what event you are yielding to. -A yield can be cancelled at any time by pressing the ESC key. You will then be given priority passes as normal. +A yield can be cancelled at any time by pressing the ESC key, or by clicking the highlighted yield button again (toggle behavior). You will then be given priority passes as normal. Yield buttons are disabled during pre-game, mulligan and cleanup/discard phases. 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 index 1ed1523036f..6de858bee57 100644 --- 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 @@ -104,60 +104,30 @@ public void update() { updateYieldButtons(); } - // Yield action methods - set yield mode directly on GUI, then pass priority - private void yieldUntilNextPhase() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_NEXT_PHASE); - if (matchUI.getGameController() != null) { - matchUI.getGameController().selectButtonOk(); - } - } - } - - private void yieldUntilStackClears() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); - if (matchUI.getGameController() != null) { - matchUI.getGameController().selectButtonOk(); - } - } - } - - private void yieldUntilCombat() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); - if (matchUI.getGameController() != null) { - matchUI.getGameController().selectButtonOk(); - } - } - } - - private void yieldUntilEndStep() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_STEP); - if (matchUI.getGameController() != null) { - matchUI.getGameController().selectButtonOk(); - } - } - } - - private void yieldUntilEndTurn() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); + /** + * Toggle yield mode: if the mode is already active, clear it; otherwise activate it. + * When activating, also pass priority. When clearing, just cancel auto-yield. + */ + private void toggleYieldMode(YieldMode mode) { + if (matchUI == null || matchUI.getCurrentPlayer() == null) return; + PlayerView player = matchUI.getCurrentPlayer(); + if (matchUI.getYieldMode(player) == mode) { + matchUI.clearYieldMode(player); + } else { + matchUI.setYieldMode(player, mode); if (matchUI.getGameController() != null) { matchUI.getGameController().selectButtonOk(); } } } - private void yieldUntilYourTurn() { - if (matchUI != null && matchUI.getCurrentPlayer() != null) { - matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); - if (matchUI.getGameController() != null) { - matchUI.getGameController().selectButtonOk(); - } - } - } + // Yield action methods - toggle yield mode on/off + private void yieldUntilNextPhase() { toggleYieldMode(YieldMode.UNTIL_NEXT_PHASE); } + private void yieldUntilStackClears() { toggleYieldMode(YieldMode.UNTIL_STACK_CLEARS); } + private void yieldUntilCombat() { toggleYieldMode(YieldMode.UNTIL_BEFORE_COMBAT); } + private void yieldUntilEndStep() { toggleYieldMode(YieldMode.UNTIL_END_STEP); } + private void yieldUntilEndTurn() { toggleYieldMode(YieldMode.UNTIL_END_OF_TURN); } + private void yieldUntilYourTurn() { toggleYieldMode(YieldMode.UNTIL_YOUR_NEXT_TURN); } /** * Update yield buttons enabled state based on game state. From bf9910bf88ca727eb10c2054335020123bd988a9 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 09:42:20 +1030 Subject: [PATCH 26/33] Consolidate yield state tracking into YieldState class - Replace multiple per-mode tracking maps with single YieldState object - Extract isAtOrAfterCombat() and isAtOrAfterEndStep() helpers - Share formatShortcutDisplayText() between YieldController and VYield - Extract isValidSuggestionContext() to reduce duplication in InputPassPriority - Add safety check for UNTIL_NEXT_PHASE when startPhase wasn't initialized Co-Authored-By: Claude Opus 4.5 --- .../forge/screens/match/views/VYield.java | 28 +-- .../gamemodes/match/YieldController.java | 219 ++++++++++-------- .../match/input/InputPassPriority.java | 30 +-- 3 files changed, 133 insertions(+), 144 deletions(-) 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 index 64f1df287c5..c4ac4fae31d 100644 --- 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 @@ -17,15 +17,9 @@ */ package forge.screens.match.views; -import java.awt.event.KeyEvent; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - import javax.swing.JPanel; -import org.apache.commons.lang3.StringUtils; - +import forge.gamemodes.match.YieldController; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; @@ -105,26 +99,8 @@ public void updateTooltips() { getShortcutDisplayText(prefs.getPref(FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN)))); } - /** - * Convert a keyboard shortcut preference string (space-separated key codes) to display text. - * e.g., "112" becomes "F1", "17 67" becomes "Ctrl C" - */ private String getShortcutDisplayText(String codeString) { - if (codeString == null || codeString.isEmpty()) { - return ""; - } - List codes = new ArrayList<>(Arrays.asList(codeString.trim().split(" "))); - List displayText = new ArrayList<>(); - for (String s : codes) { - if (!s.isEmpty()) { - try { - displayText.add(KeyEvent.getKeyText(Integer.parseInt(s))); - } catch (NumberFormatException e) { - displayText.add(s); - } - } - } - return StringUtils.join(displayText, '+'); + return YieldController.formatShortcutDisplayText(codeString); } @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 e787ce1abc1..e549135c761 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -65,15 +65,24 @@ public interface YieldCallback { // Legacy auto-pass tracking private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); + /** + * Consolidated yield state for a player. + * Tracks mode and all mode-specific timing data. + */ + private static class YieldState { + YieldMode mode; + Integer startTurn; // For UNTIL_END_OF_TURN, UNTIL_BEFORE_COMBAT, UNTIL_END_STEP + Boolean startedAtOrAfterPhase; // For UNTIL_BEFORE_COMBAT and UNTIL_END_STEP + forge.game.phase.PhaseType startPhase; // For UNTIL_NEXT_PHASE + Boolean startedDuringOurTurn; // For UNTIL_YOUR_NEXT_TURN + + YieldState(YieldMode mode) { + this.mode = mode; + } + } + // Extended yield mode tracking (experimental feature) - private final Map playerYieldMode = Maps.newHashMap(); - private final Map yieldStartTurn = Maps.newHashMap(); // Track turn when yield was set - private final Map yieldCombatStartTurn = Maps.newHashMap(); // Track turn when combat yield was set - private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); // Was yield set at/after combat? - private final Map yieldEndStepStartTurn = Maps.newHashMap(); // Track turn when end step yield was set - private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); // Was yield set at/after end step? - private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); // Was yield set during our turn? - private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); // Track phase when next phase yield was set + private final Map yieldStates = Maps.newHashMap(); // Smart suggestion decline tracking (reset each turn) private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); @@ -143,8 +152,9 @@ public void updateAutoPassPrompt(PlayerView player) { } // Check experimental yield modes - YieldMode mode = playerYieldMode.get(player); - if (mode != null && mode != YieldMode.NONE) { + YieldState state = yieldStates.get(player); + if (state != null && state.mode != null && state.mode != YieldMode.NONE) { + YieldMode mode = state.mode; callback.cancelAwaitNextInput(); Localizer loc = Localizer.getInstance(); String cancelKey = getCancelShortcutDisplayText(); @@ -184,7 +194,9 @@ public void setYieldMode(PlayerView player, final YieldMode mode) { // (legacy check in shouldAutoYieldForPlayer runs first and would override experimental mode) autoPassUntilEndOfTurn.remove(player); - playerYieldMode.put(player, mode); + YieldState state = new YieldState(mode); + yieldStates.put(player, state); + GameView gameView = callback.getGameView(); // Use network-safe GameView properties instead of gameView.getGame() @@ -197,32 +209,27 @@ public void setYieldMode(PlayerView player, final YieldMode mode) { int currentTurn = gameView.getTurn(); PlayerView currentPlayerTurn = gameView.getPlayerTurn(); - // Track current phase for UNTIL_NEXT_PHASE mode - if (mode == YieldMode.UNTIL_NEXT_PHASE) { - yieldNextPhaseStartPhase.put(player, phase); - } - // Track turn number for UNTIL_END_OF_TURN mode - if (mode == YieldMode.UNTIL_END_OF_TURN) { - yieldStartTurn.put(player, currentTurn); - } - // Track turn and phase state for UNTIL_BEFORE_COMBAT mode - if (mode == YieldMode.UNTIL_BEFORE_COMBAT) { - yieldCombatStartTurn.put(player, currentTurn); - boolean atOrAfterCombat = phase != null && - (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); - } - // Track turn and phase state for UNTIL_END_STEP mode - if (mode == YieldMode.UNTIL_END_STEP) { - yieldEndStepStartTurn.put(player, currentTurn); - boolean atOrAfterEndStep = phase != null && - (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); - yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); - } - // Track if UNTIL_YOUR_NEXT_TURN was started during our turn - if (mode == YieldMode.UNTIL_YOUR_NEXT_TURN) { - boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); - yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); + // Track mode-specific state + switch (mode) { + case UNTIL_NEXT_PHASE: + state.startPhase = phase; + break; + case UNTIL_END_OF_TURN: + state.startTurn = currentTurn; + break; + case UNTIL_BEFORE_COMBAT: + state.startTurn = currentTurn; + state.startedAtOrAfterPhase = isAtOrAfterCombat(phase); + break; + case UNTIL_END_STEP: + state.startTurn = currentTurn; + state.startedAtOrAfterPhase = isAtOrAfterEndStep(phase); + break; + case UNTIL_YOUR_NEXT_TURN: + state.startedDuringOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); + break; + default: + break; } } @@ -264,21 +271,14 @@ public void setYieldModeSilent(PlayerView player, YieldMode mode) { // Clear legacy auto-pass to prevent interference autoPassUntilEndOfTurn.remove(player); // Just set the mode - detailed tracking is managed by server - playerYieldMode.put(player, mode); + yieldStates.put(player, new YieldState(mode)); } /** * Internal method to clear yield state without callbacks. */ private void clearYieldModeInternal(PlayerView player) { - playerYieldMode.remove(player); - yieldStartTurn.remove(player); - yieldCombatStartTurn.remove(player); - yieldCombatStartedAtOrAfterCombat.remove(player); - yieldEndStepStartTurn.remove(player); - yieldEndStepStartedAtOrAfterEndStep.remove(player); - yieldYourTurnStartedDuringOurTurn.remove(player); - yieldNextPhaseStartPhase.remove(player); + yieldStates.remove(player); autoPassUntilEndOfTurn.remove(player); // Legacy compatibility } @@ -291,8 +291,8 @@ public YieldMode getYieldMode(PlayerView player) { if (autoPassUntilEndOfTurn.contains(player)) { return YieldMode.UNTIL_END_OF_TURN; } - YieldMode mode = playerYieldMode.get(player); - return mode != null ? mode : YieldMode.NONE; + YieldState state = yieldStates.get(player); + return state != null && state.mode != null ? state.mode : YieldMode.NONE; } /** @@ -320,8 +320,8 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { return false; } - YieldMode mode = playerYieldMode.get(player); - if (mode == null || mode == YieldMode.NONE) { + YieldState state = yieldStates.get(player); + if (state == null || state.mode == null || state.mode == YieldMode.NONE) { return false; } @@ -342,14 +342,25 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { int currentTurn = gameView.getTurn(); PlayerView currentPlayerTurn = gameView.getPlayerTurn(); - return switch (mode) { + return switch (state.mode) { case UNTIL_NEXT_PHASE -> { - forge.game.phase.PhaseType startPhase = yieldNextPhaseStartPhase.get(player); - if (startPhase == null) { - yieldNextPhaseStartPhase.put(player, currentPhase); + if (state.startPhase == null) { + // startPhase wasn't set in setYieldMode (gameView was null or timing issue). + // Set it now, but only continue if we're in a "starting" phase. + // If we appear to be past the starting point (e.g., in M2 when we + // probably started in M1), end the yield to avoid skipping too far. + state.startPhase = currentPhase; + + // Safety check: if this is the second main phase and we just set + // startPhase, we likely missed our stop point due to timing + if (currentPhase == forge.game.phase.PhaseType.MAIN2) { + clearYieldMode(player); + yieldJustEnded.add(player); + yield false; + } yield true; } - if (currentPhase != startPhase) { + if (currentPhase != state.startPhase) { clearYieldMode(player); yieldJustEnded.add(player); yield false; @@ -368,13 +379,12 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { } case UNTIL_END_OF_TURN -> { // Yield until end of the turn when yield was set - clear when turn number changes - Integer startTurn = yieldStartTurn.get(player); - if (startTurn == null) { + if (state.startTurn == null) { // Turn wasn't tracked when yield was set - track it now - yieldStartTurn.put(player, currentTurn); + state.startTurn = currentTurn; yield true; } - if (currentTurn > startTurn) { + if (currentTurn > state.startTurn) { clearYieldMode(player); yieldJustEnded.add(player); yield false; @@ -384,54 +394,42 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { case UNTIL_YOUR_NEXT_TURN -> { // Yield until our turn starts - use PlayerView comparison (network-safe) boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); - Boolean startedDuringOurTurn = yieldYourTurnStartedDuringOurTurn.get(player); - if (startedDuringOurTurn == null) { + if (state.startedDuringOurTurn == null) { // Tracking wasn't set - initialize it now - yieldYourTurnStartedDuringOurTurn.put(player, isOurTurn); - startedDuringOurTurn = isOurTurn; + state.startedDuringOurTurn = isOurTurn; } if (isOurTurn) { // If we started during our turn, we need to wait until it's our turn AGAIN // (i.e., we left our turn and came back) // If we started during opponent's turn, stop when we reach our turn - if (!Boolean.TRUE.equals(startedDuringOurTurn)) { + if (!Boolean.TRUE.equals(state.startedDuringOurTurn)) { clearYieldMode(player); yieldJustEnded.add(player); yield false; } } else { // Not our turn - if we started during our turn, mark that we've left it - if (Boolean.TRUE.equals(startedDuringOurTurn)) { + if (Boolean.TRUE.equals(state.startedDuringOurTurn)) { // We've left our turn, now waiting for it to come back - yieldYourTurnStartedDuringOurTurn.put(player, false); + state.startedDuringOurTurn = false; } } yield true; } case UNTIL_BEFORE_COMBAT -> { - Integer startTurn = yieldCombatStartTurn.get(player); - Boolean startedAtOrAfterCombat = yieldCombatStartedAtOrAfterCombat.get(player); - - if (startTurn == null) { + if (state.startTurn == null) { // Tracking wasn't set - initialize it now - yieldCombatStartTurn.put(player, currentTurn); - boolean atOrAfterCombat = currentPhase != null && - (currentPhase == forge.game.phase.PhaseType.COMBAT_BEGIN || currentPhase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - yieldCombatStartedAtOrAfterCombat.put(player, atOrAfterCombat); - startTurn = currentTurn; - startedAtOrAfterCombat = atOrAfterCombat; + state.startTurn = currentTurn; + state.startedAtOrAfterPhase = isAtOrAfterCombat(currentPhase); } // Check if we should stop: we're at or past combat on a DIFFERENT turn than when we started, // OR we're at combat on the SAME turn but we started BEFORE combat - boolean atOrAfterCombatNow = currentPhase != null && - (currentPhase == forge.game.phase.PhaseType.COMBAT_BEGIN || currentPhase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); - - if (atOrAfterCombatNow) { - boolean differentTurn = currentTurn > startTurn; - boolean sameTurnButStartedBeforeCombat = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterCombat); + if (isAtOrAfterCombat(currentPhase)) { + boolean differentTurn = currentTurn > state.startTurn; + boolean sameTurnButStartedBeforeCombat = (currentTurn == state.startTurn.intValue()) && !Boolean.TRUE.equals(state.startedAtOrAfterPhase); if (differentTurn || sameTurnButStartedBeforeCombat) { clearYieldMode(player); @@ -442,27 +440,17 @@ public boolean shouldAutoYieldForPlayer(PlayerView player) { yield true; } case UNTIL_END_STEP -> { - Integer startTurn = yieldEndStepStartTurn.get(player); - Boolean startedAtOrAfterEndStep = yieldEndStepStartedAtOrAfterEndStep.get(player); - - if (startTurn == null) { + if (state.startTurn == null) { // Tracking wasn't set - initialize it now - yieldEndStepStartTurn.put(player, currentTurn); - boolean atOrAfterEndStep = currentPhase != null && - (currentPhase == forge.game.phase.PhaseType.END_OF_TURN || currentPhase == forge.game.phase.PhaseType.CLEANUP); - yieldEndStepStartedAtOrAfterEndStep.put(player, atOrAfterEndStep); - startTurn = currentTurn; - startedAtOrAfterEndStep = atOrAfterEndStep; + state.startTurn = currentTurn; + state.startedAtOrAfterPhase = isAtOrAfterEndStep(currentPhase); } // Check if we should stop: we're at or past end step on a DIFFERENT turn than when we started, // OR we're at end step on the SAME turn but we started BEFORE end step - boolean atOrAfterEndStepNow = currentPhase != null && - (currentPhase == forge.game.phase.PhaseType.END_OF_TURN || currentPhase == forge.game.phase.PhaseType.CLEANUP); - - if (atOrAfterEndStepNow) { - boolean differentTurn = currentTurn > startTurn; - boolean sameTurnButStartedBeforeEndStep = (currentTurn == startTurn.intValue()) && !Boolean.TRUE.equals(startedAtOrAfterEndStep); + if (isAtOrAfterEndStep(currentPhase)) { + boolean differentTurn = currentTurn > state.startTurn; + boolean sameTurnButStartedBeforeEndStep = (currentTurn == state.startTurn.intValue()) && !Boolean.TRUE.equals(state.startedAtOrAfterPhase); if (differentTurn || sameTurnButStartedBeforeEndStep) { clearYieldMode(player); @@ -534,7 +522,8 @@ private boolean shouldInterruptYield(final PlayerView player) { if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { - YieldMode mode = playerYieldMode.get(player); + YieldState state = yieldStates.get(player); + YieldMode mode = state != null ? state.mode : null; // Don't interrupt UNTIL_END_OF_TURN on our own turn boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); if (!(mode == YieldMode.UNTIL_END_OF_TURN && isOurTurn)) { @@ -688,6 +677,22 @@ private boolean isYieldExperimentalEnabled() { return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); } + /** + * Check if the phase is at or after the beginning of combat. + */ + private boolean isAtOrAfterCombat(forge.game.phase.PhaseType phase) { + return phase != null && + (phase == forge.game.phase.PhaseType.COMBAT_BEGIN || phase.isAfter(forge.game.phase.PhaseType.COMBAT_BEGIN)); + } + + /** + * Check if the phase is at or after the end step. + */ + private boolean isAtOrAfterEndStep(forge.game.phase.PhaseType phase) { + return phase != null && + (phase == forge.game.phase.PhaseType.END_OF_TURN || phase == forge.game.phase.PhaseType.CLEANUP); + } + /** * Get the total number of players in the game. * Uses network-safe GameView.getPlayers() instead of Game.getPlayers(). @@ -757,11 +762,11 @@ public void removeFromLegacyAutoPass(PlayerView player) { } /** - * Get the display text for the yield cancel keyboard shortcut. - * @return Human-readable shortcut text, e.g., "Escape" or "Ctrl+Escape" + * Convert a keyboard shortcut preference string to display text. + * @param codeString Space-separated key codes (e.g., "17 67" for Ctrl+C) + * @return Human-readable shortcut text (e.g., "Ctrl+C") */ - public String getCancelShortcutDisplayText() { - String codeString = FModel.getPreferences().getPref(FPref.SHORTCUT_YIELD_CANCEL); + public static String formatShortcutDisplayText(String codeString) { if (codeString == null || codeString.isEmpty()) { return ""; } @@ -778,4 +783,12 @@ public String getCancelShortcutDisplayText() { } return String.join("+", displayText); } + + /** + * Get the display text for the yield cancel keyboard shortcut. + * @return Human-readable shortcut text, e.g., "Escape" or "Ctrl+Escape" + */ + public String getCancelShortcutDisplayText() { + return formatShortcutDisplayText(FModel.getPreferences().getPref(FPref.SHORTCUT_YIELD_CANCEL)); + } } 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 2a63e9ac22d..2dd024cb58b 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 @@ -355,18 +355,25 @@ private boolean shouldShowStackYieldPrompt() { return !pv.hasAvailableActions(); } - private boolean shouldShowNoManaPrompt() { - GameView gv = getGameView(); - PlayerView pv = getPlayerView(); - if (gv == null || pv == null) return false; - + /** + * Check if current game state is valid for showing yield suggestions. + * Returns false if stack is non-empty or it's the player's turn. + */ + 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)) { + return currentTurn == null || !currentTurn.equals(pv); + } + + private boolean shouldShowNoManaPrompt() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + + if (!isValidSuggestionContext(gv, pv)) { return false; } @@ -402,17 +409,10 @@ private boolean shouldShowNoActionsPrompt() { PlayerView pv = getPlayerView(); 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)) { + if (!isValidSuggestionContext(gv, pv)) { return false; } - // Use TrackableProperty return !pv.hasAvailableActions(); } } From cbcd3732a9c320ed9f0cca79d4d99e7aa53e3560 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 10:49:56 +1030 Subject: [PATCH 27/33] Make hasAvailableActions computation conditional on experimental yields Move hasManaAvailable() helper from InputPassPriority to PlayerView per reviewer feedback to keep helpers closer to their intended usage. Add trackAvailableActions flag to GameRules to control whether the expensive hasAvailableActions() computation runs on priority passes. The flag is set based on YIELD_EXPERIMENTAL_OPTIONS preference. Network play note: The computation is controlled by the host's preferences. Checking per-client preferences would require network protocol changes (clients communicating preferences during match setup), which adds complexity not warranted for an experimental feature. Individual suggestion toggles still control client-side display independently. Co-Authored-By: Claude Opus 4.5 --- .../src/main/java/forge/game/GameRules.java | 10 ++++++++ .../java/forge/game/phase/PhaseHandler.java | 10 ++++---- .../java/forge/game/player/PlayerView.java | 23 +++++++++++++++++++ .../forge/gamemodes/match/HostedMatch.java | 5 ++++ .../match/input/InputPassPriority.java | 22 +----------------- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/forge-game/src/main/java/forge/game/GameRules.java b/forge-game/src/main/java/forge/game/GameRules.java index c38b6c113c9..e33731cf72a 100644 --- a/forge-game/src/main/java/forge/game/GameRules.java +++ b/forge-game/src/main/java/forge/game/GameRules.java @@ -17,6 +17,9 @@ public class GameRules { private final Set appliedVariants = EnumSet.noneOf(GameType.class); private int simTimeout = 120; + // Whether to track available actions for yield suggestions (performance optimization) + private boolean trackAvailableActions = false; + // it's a preference, not rule... but I could hardly find a better place for it private boolean useGrayText; @@ -133,4 +136,11 @@ public int getSimTimeout() { public void setSimTimeout(final int duration) { this.simTimeout = duration; } + + public boolean tracksAvailableActions() { + return trackAvailableActions; + } + public void setTrackAvailableActions(boolean track) { + this.trackAvailableActions = track; + } } diff --git a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java index 710150c700f..a625c46ee4e 100644 --- a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java +++ b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java @@ -1164,10 +1164,12 @@ else if (!game.getStack().hasSimultaneousStackEntries()) { p.setHasPriority(getPriorityPlayer() == p); } - // Update available actions for the player receiving priority (for network-safe yield suggestions) - Player priorityPlayer = getPriorityPlayer(); - if (priorityPlayer != null) { - priorityPlayer.updateAvailableActionsForView(); + // Update available actions for yield suggestions (only if tracking enabled) + if (game.getRules().tracksAvailableActions()) { + Player priorityPlayer = getPriorityPlayer(); + if (priorityPlayer != null) { + priorityPlayer.updateAvailableActionsForView(); + } } } 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 cf3798ad974..cfd8d4200aa 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -564,6 +564,29 @@ void updateHasAvailableActions(Player p) { set(TrackableProperty.HasAvailableActions, p.hasAvailableActions()); } + /** + * Check if player has any mana available (floating or from untapped lands). + * Used by yield suggestion system to determine if player can cast spells. + */ + public boolean hasManaAvailable() { + // Check floating mana + for (byte manaType : ManaAtom.MANATYPES) { + if (getMana(manaType) > 0) return true; + } + + // Check for untapped lands + FCollectionView battlefield = getBattlefield(); + if (battlefield != null) { + for (CardView cv : battlefield) { + if (!cv.isTapped() && cv.getCurrentState().isLand()) { + return true; + } + } + } + + return false; + } + public boolean willLoseManaAtEndOfPhase() { Boolean val = get(TrackableProperty.WillLoseManaAtEndOfPhase); return val != null && val; 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 85f6db690e3..78a0c340569 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -76,6 +76,11 @@ private static GameRules getDefaultRules(final GameType gameType) { gameRules.setOrderCombatants(FModel.getPreferences().getPrefBoolean(FPref.LEGACY_ORDER_COMBATANTS)); gameRules.setUseGrayText(FModel.getPreferences().getPrefBoolean(FPref.UI_GRAY_INACTIVE_TEXT)); gameRules.setGamesPerMatch(FModel.getPreferences().getPrefInt(FPref.UI_MATCHES_PER_GAME)); + // Enable available actions tracking when experimental yield features are on. + // Individual suggestion toggles control client display, not computation. + // Checking per-client preferences would require network protocol changes. + gameRules.setTrackAvailableActions( + FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)); // AI specific sideboarding rules switch (AiProfileUtil.getAISideboardingMode()) { case Off: 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 2dd024cb58b..203a0225049 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 @@ -17,7 +17,6 @@ */ package forge.gamemodes.match.input; -import forge.card.mana.ManaAtom; import forge.game.Game; import forge.game.GameView; import forge.game.card.Card; @@ -382,26 +381,7 @@ private boolean shouldShowNoManaPrompt() { return false; } - return !hasManaAvailable(pv); - } - - private boolean hasManaAvailable(PlayerView pv) { - // Check floating mana - for (byte manaType : ManaAtom.MANATYPES) { - if (pv.getMana(manaType) > 0) return true; - } - - // Check for untapped lands (simplified check using view data) - FCollectionView battlefield = pv.getBattlefield(); - if (battlefield != null) { - for (CardView cv : battlefield) { - if (!cv.isTapped() && cv.getCurrentState().isLand()) { - return true; - } - } - } - - return false; + return !pv.hasManaAvailable(); } private boolean shouldShowNoActionsPrompt() { From 9db3049e1228a10b31842d668f23a7f573da3ca7 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 13:19:29 +1030 Subject: [PATCH 28/33] Add toggles for suggestion suppression behavior Adds two toggleable preferences to control when yield suggestions are suppressed: - "Suppress On Own Turn" (default ON): Suppresses suggestions during the player's own turn (always suppressed on first turn regardless) - "Suppress After Yield Ends" (default ON): Suppresses suggestions for one priority pass after a yield expires or is interrupted Both options appear in Game > Yield Options > Automatic Suggestions. Updates Expanded-Yield-Options.md to document both behaviors. Co-Authored-By: Claude Opus 4.5 --- docs/Expanded-Yield-Options.md | 2 ++ .../forge/screens/match/menus/GameMenu.java | 3 +++ forge-gui/res/languages/en-US.properties | 2 ++ .../match/input/InputPassPriority.java | 27 ++++++++++++++----- .../properties/ForgePreferences.java | 2 ++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/Expanded-Yield-Options.md b/docs/Expanded-Yield-Options.md index 81f3fb25a05..8c1f13201b8 100644 --- a/docs/Expanded-Yield-Options.md +++ b/docs/Expanded-Yield-Options.md @@ -79,6 +79,8 @@ When enabled, the system detects situations where you likely cannot take action - Suggestions will not appear if you're already yielding - Declining a suggestion suppresses that kind of suggestion until the next turn (i.e. this stops you repeatedly recieving the same prompt). - Clicking a yield button while a suggestion is showing activates the clicked yield mode instead of the suggested one. +- **On your own turn:** By default, the "no mana" and "no actions" suggestions are suppressed on your own turn since you typically want to take actions during your turn. This can be disabled in Game > Yield Options > Automatic Suggestions > "Suppress On Own Turn". Note: Suggestions are always suppressed on your first turn regardless of this setting, since you won't have any lands or mana yet. +- **After a yield ends:** By default, suggestions are suppressed for one priority pass when a yield expires or is interrupted. This gives you time to assess the game state before deciding whether to re-yield. The system assumes you may want to take an action at the moment the yield ends. This behavior can be disabled in Game > Yield Options > Automatic Suggestions > "Suppress After Yield Ends". 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 e680c89c37b..b926bce9a69 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 @@ -234,6 +234,9 @@ private JMenu getYieldOptionsMenu() { suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestStackYield"), FPref.YIELD_SUGGEST_STACK_YIELD)); suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestNoMana"), FPref.YIELD_SUGGEST_NO_MANA)); suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuggestNoActions"), FPref.YIELD_SUGGEST_NO_ACTIONS)); + suggestionsMenu.addSeparator(); + suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuppressOnOwnTurn"), FPref.YIELD_SUPPRESS_ON_OWN_TURN)); + suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuppressAfterYield"), FPref.YIELD_SUPPRESS_AFTER_END)); yieldMenu.add(suggestionsMenu); // Sub-menu 3: Display Options diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 10d212b720c..c0d94c41528 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1554,6 +1554,8 @@ lblInterruptOnMassRemoval=When mass removal spell cast lblSuggestStackYield=When can't respond to stack lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available +lblSuppressOnOwnTurn=Suppress On Own Turn +lblSuppressAfterYield=Suppress After Yield Ends lblDisplayOptions=Display Options lblShowRightClickMenu=Show Right-Click Menu lblYieldBtnNextPhase=Next Phase 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 203a0225049..b14234c2f13 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 @@ -68,10 +68,12 @@ public InputPassPriority(final PlayerControllerHuman controller) { @Override public final void showMessage() { // Check if experimental yield features are enabled and show smart suggestions - // Only show suggestions if not already yielding and yield didn't just end - // (suppresses suggestions immediately after a yield expires or is interrupted) - if (isExperimentalYieldEnabled() && !isAlreadyYielding() - && !getController().getGui().didYieldJustEnd(getOwner())) { + // Only show suggestions if not already yielding + // Check if yield just ended and suppression is enabled + boolean suppressDueToYieldEnd = FModel.getPreferences().getPrefBoolean(FPref.YIELD_SUPPRESS_AFTER_END) + && getController().getGui().didYieldJustEnd(getOwner()); + + if (isExperimentalYieldEnabled() && !isAlreadyYielding() && !suppressDueToYieldEnd) { ForgePreferences prefs = FModel.getPreferences(); Localizer loc = Localizer.getInstance(); @@ -356,15 +358,28 @@ private boolean shouldShowStackYieldPrompt() { /** * Check if current game state is valid for showing yield suggestions. - * Returns false if stack is non-empty or it's the player's turn. + * Returns false if stack is non-empty or if own-turn suppression applies. */ private boolean isValidSuggestionContext(GameView gv, PlayerView pv) { FCollectionView stack = gv.getStack(); if (stack != null && !stack.isEmpty()) { return false; } + // Check if it's the player's own turn PlayerView currentTurn = gv.getPlayerTurn(); - return currentTurn == null || !currentTurn.equals(pv); + if (currentTurn != null && currentTurn.equals(pv)) { + // Always suppress on player's first turn (no lands/mana yet) + // First round = turn number <= player count + int numPlayers = gv.getPlayers().size(); + if (gv.getTurn() <= numPlayers) { + return false; + } + // Otherwise check the preference + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_SUPPRESS_ON_OWN_TURN)) { + return false; + } + } + return true; } private boolean shouldShowNoManaPrompt() { 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 cff2f5897ec..92511b1f42f 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -148,6 +148,8 @@ public enum FPref implements PreferencesStore.IPref { YIELD_INTERRUPT_ON_REVEAL("false"), // When opponent reveals cards YIELD_INTERRUPT_ON_MASS_REMOVAL("true"), // When mass removal spell cast YIELD_SHOW_RIGHT_CLICK_MENU("false"), // Show right-click yield menu on End Turn + YIELD_SUPPRESS_ON_OWN_TURN("true"), // Suppress suggestions on player's own turn + YIELD_SUPPRESS_AFTER_END("true"), // Suppress suggestions for one priority pass after yield ends UI_STACK_EFFECT_NOTIFICATION_POLICY ("Never"), UI_LAND_PLAYED_NOTIFICATION_POLICY ("Never"), From 925c37c0fa8ea611b045e0a3e1b37b8926100630 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 15:00:46 +1030 Subject: [PATCH 29/33] Remove DOCUMENTATION.md (moved to NetworkPlay/dev) Documentation preserved in NetworkPlay/dev:.documentation/YieldRework-Documentation.md Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 929 ----------------------------------------------- 1 file changed, 929 deletions(-) delete mode 100644 DOCUMENTATION.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md deleted file mode 100644 index 2e41a1f102f..00000000000 --- a/DOCUMENTATION.md +++ /dev/null @@ -1,929 +0,0 @@ -# Yield System Rework - PR Documentation - -## Summary - -This PR adds an expanded yield system to reduce micromanagement in multiplayer games. The feature is **disabled by default** and must be explicitly enabled in preferences. - -## Problem Statement - -In multiplayer Magic games (3+ players), the current priority system requires excessive clicking: -- Dozens of priority passes every turn in multiplayer game -- Players must manually pass priority even when they have no possible actions -- This can create click fatigue and slow down gameplay significantly - -## Solution - -Extended yield options that allow players to automatically pass priority until specific conditions are met, set yield interrupts for important game events, and smart suggestions prompting players to enable auto-yield in situations where they cannot take actions. All configurable through in-game menu options. - -## Feature Overview - -### Yield Modes - -| Mode | Description | End Condition | Availability | -|------|-------------|---------------|--------------| -| Next Phase | Auto-pass until phase changes | Any phase transition | Always | -| Next Turn | Auto-pass until next turn | Turn number changes | Always | -| Until Stack Clears | Auto-pass while stack has items | Stack becomes empty (including simultaneous triggers) | Always | -| Until Before Combat | Auto-pass until combat begins | Next COMBAT_BEGIN phase (tracks start turn/phase) | Always | -| Until End Step | Auto-pass until end step | Next END_OF_TURN phase (tracks start turn/phase) | Always | -| Until Your Next Turn | Auto-pass until you become active player | Your turn starts again (tracks if started during own turn) | 3+ player games only | - -### Access Methods - -1. **Yield Options Panel**: A dockable panel with dedicated yield buttons in a 2-row layout: - - **Row 1:** - - **Next Phase** - Yield until next phase begins - - **Combat** - Yield until before combat - - **End Step** - Yield until end step - - **Row 2:** - - **End Turn** - Yield until next turn - - **Your Turn** - Yield until your next turn (only visible in 3+ player games) - - **Clear Stack** - Yield until stack clears (only enabled when stack has items) - - **Visual Feedback:** - - Buttons are **blue** by default, **red** when that yield mode is active - - Panel appears as a tab alongside the Stack panel when experimental yields are enabled - - All buttons disabled during mulligan, pre-game, and cleanup/discard phases - -2. **Right-Click Menu**: Right-click the "End Turn" button to see yield options (configurable) - -3. **Keyboard Shortcuts** (F2-F7 to avoid conflict with F1=Help): - - `F2` - Yield until next phase - - `F3` - Yield until before combat - - `F4` - Yield until end step - - `F5` - Yield until next turn - - `F6` - Yield until your next turn (3+ players) - - `F7` - Yield until stack clears - - `ESC` - Cancel active yield - -### Smart Yield Suggestions - -When enabled, the system prompts players to enable auto-yield in situations where they likely cannot act. Suggestions are **integrated into the prompt area** (not modal dialogs) with Accept/Decline buttons: - -1. **Cannot respond to stack** (`YIELD_SUGGEST_STACK_YIELD`): Player has no instant-speed responses available - - Checks if stack has items - - Uses `getAllPossibleAbilities(removeUnplayable=true)` to verify no responses - - Suggests `UNTIL_STACK_CLEARS` mode - -2. **No mana available** (`YIELD_SUGGEST_NO_MANA`): Player has cards but no mana sources untapped - - Only triggers when not on player's turn - - Checks for untapped lands with mana abilities or mana in pool - - Suggests default yield mode (based on game type) - -3. **No actions available** (`YIELD_SUGGEST_NO_ACTIONS`): No playable cards in hand and no activatable non-mana abilities - - Only triggers when not on player's turn and stack is empty - - Uses `getAllPossibleAbilities(removeUnplayable=true)` to verify - - Suggests default yield mode (based on game type) - -**Suggestion Behavior:** -- Each suggestion type can be individually enabled/disabled via preferences -- Suggestions will **not appear** if: - - The player is already yielding - - The suggestion was declined earlier in the same turn (auto-suppression) -- Declining a suggestion shows hint: "(Declining disables this prompt until next turn)" -- Suppression automatically resets when turn number changes -- If a yield button is clicked while a suggestion is showing, the clicked yield mode takes precedence - -### Interrupt Conditions - -Existing interrupt conditions while on auto-yield are now configurable through in-game options menu. -Yield modes can be configured to automatically cancel when: -- Attackers are declared against **you specifically** (default: ON) - uses `getAttackersOf(player)` to only trigger when creatures attack you, not when any player is attacked -- **You** can declare blockers (default: ON) - only triggers when creatures are attacking you -- **You or your permanents** are targeted by a spell/ability (default: ON) -- An opponent casts any spell (default: OFF) -- Combat begins (default: OFF) -- Cards are revealed or choices are made (default: OFF) - when **disabled**, reveal dialogs and opponent choice notifications are auto-dismissed during yield -- Mass removal spell cast by opponent (default: ON) - detects DestroyAll, ChangeZoneAll (exile/graveyard), DamageAll, SacrificeAll effects; only interrupts if you have permanents matching the spell's filter - -**Multiplayer Note:** Attack/blocker interrupts are scoped to the individual player - if Player A attacks Player B, Player C's yield will NOT be interrupted. - -## How to Enable - -1. Open Forge Preferences -2. Find `Experimental Yield Options` -3. Set to `true` -4. Restart the game - -Once enabled: -- Right-click menu appears on End Turn button -- Keyboard shortcuts become active -- Yield Options submenu appears in: Forge > Game > Yield Options. -- Smart suggestions begin appearing (if enabled) - -## Technical Implementation - -### Architecture Overview - -The yield system is implemented entirely in the **GUI layer** with zero changes to the core game engine or network protocol. This design ensures backward compatibility and allows each client to manage its own yield preferences independently. - -#### Component Hierarchy - -``` -┌─────────────────────────────────────────────────────────────┐ -│ GUI Layer (Client) │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ Desktop UI Components (forge-gui-desktop) │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ -│ │ │ VYield │ │ CYield │ │ VPrompt │ │ │ -│ │ │ (View) │ │ (Ctrl) │ │ (Menu) │ │ │ -│ │ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ │ -│ └────────┼─────────────┼─────────────┼─────────────────┘ │ -│ │ │ │ │ -│ ┌────────┴─────────────┴─────────────┴─────────────────┐ │ -│ │ Shared GUI Logic (forge-gui) │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ AbstractGuiGame │ │ │ -│ │ │ (Implements IGuiGame interface) │ │ │ -│ │ │ │ │ │ -│ │ │ ┌─────────────────────────────────┐ │ │ │ -│ │ │ │ YieldController (delegate) │ │ │ │ -│ │ │ │ - State management │ │ │ │ -│ │ │ │ - Interrupt logic │ │ │ │ -│ │ │ │ - End condition checks │ │ │ │ -│ │ │ └─────────────┬───────────────────┘ │ │ │ -│ │ │ ▲ │ │ │ -│ │ │ │ YieldCallback │ │ │ -│ │ │ │ (for GUI updates) │ │ │ -│ │ └────────────────┼───────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌────────────────┴───────────────────────────────┐ │ │ -│ │ │ InputPassPriority │ │ │ -│ │ │ - Smart suggestions │ │ │ -│ │ │ - Prompt integration │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ └────────────────────────┬───────────────────────────────┘ │ -└───────────────────────────┼──────────────────────────────────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ IGameController Interface │ - │ (Priority pass abstraction) │ - └──────────────┬──────────────────────┘ - │ - ┌──────────────┴──────────────┐ - ▼ ▼ -┌──────────────────────┐ ┌──────────────────────────┐ -│ PlayerControllerHuman│ │ NetGameController │ -│ (Local games) │ │ (Network games) │ -└──────────────────────┘ └──────────┬───────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ Network Protocol │ - │ (unchanged) │ - │ - Standard priority │ - │ pass messages only │ - └──────────────────────────┘ -``` - -#### Key Components - -**1. YieldController** (New - `forge-gui/YieldController.java`) -- **Purpose**: Core yield logic and state management -- **Responsibilities**: - - Manages yield state maps for each player - - Implements interrupt condition checking - - Evaluates mode-specific end conditions - - Provides YieldCallback interface for GUI updates -- **State Tracking**: Uses Maps keyed by PlayerView to track: - - `playerYieldMode` - Current yield mode per player - - `yieldStartTurn` - Turn number when yield was set - - `yieldCombatStartTurn` - Turn when combat yield was set - - `yieldNextPhaseStartPhase` - Phase when next phase yield was set - - `declinedSuggestionsThisTurn` - Declined suggestion tracking -- **Design Pattern**: Uses callback pattern to decouple from GUI - -**2. AbstractGuiGame** (`forge-gui/AbstractGuiGame.java`) -- **Purpose**: GUI game implementation that delegates to YieldController -- **Responsibilities**: - - Lazily initializes YieldController with callback implementation - - Exposes yield methods through IGuiGame interface - - Provides callback implementations for GUI updates -- **Delegation**: All yield operations delegate to `getYieldController()` - ```java - public void setYieldMode(PlayerView player, YieldMode mode) { - getYieldController().setYieldMode(player, mode); - } - ``` -- **Design Pattern**: Delegate pattern for separation of concerns - -**3. InputPassPriority** (`forge-gui/InputPassPriority.java`) -- **Purpose**: Priority pass input handler with smart suggestions -- **Responsibilities**: - - Detects situations where yield suggestions are helpful - - Integrates suggestions into prompt area (not modal dialogs) - - Tracks pending suggestion state - - Respects decline tracking (suppression per turn) -- **Integration**: Checks experimental yield flag and player yield state before showing suggestions - -**4. Desktop UI Components** (`forge-gui-desktop/`) -- **VYield**: Yield panel view with 6 buttons in 2-row layout - - Row 1: Next Phase | Combat | End Step - - Row 2: End Turn | Your Turn | Clear Stack - - Uses `FButton.setUseHighlightMode(true)` for blue/red coloring - - Dynamic tooltip updates with keyboard shortcuts -- **CYield**: Controller that registers action listeners and updates button states -- **VPrompt**: Right-click menu on End Turn button (if preference enabled) - -#### Network Independence - -**Client-Local State:** -- Each client maintains its own `YieldController` instance -- Yield modes are **never synchronized** between clients -- No yield state is sent over the network - -**Protocol Compatibility:** -- Yield system only affects **when** priority is passed, not **how** -- Uses existing `selectButtonOk()` / `passPriority()` protocol methods -- Network layer sees only standard priority pass messages -- NetGameController implements IGameController with zero yield-specific methods - -**Example Multi-Player Scenario:** -``` -3-Player Game: -- Player A: Sets UNTIL_YOUR_NEXT_TURN (auto-passing in background) -- Player B: Sets UNTIL_COMBAT (auto-passing in background) -- Player C: Manual priority passing - -Network traffic from all three players: -- A sends: passPriority message (automated by yield system) -- B sends: passPriority message (automated by yield system) -- C sends: passPriority message (manual click) - -Server behavior: Identical for all three - no awareness of yield state -``` - -#### Data Flow - -**1. User Activates Yield:** -``` -User clicks yield button (VYield) - ↓ -CYield calls matchUI.setYieldMode(player, mode) - ↓ -AbstractGuiGame.setYieldMode(player, mode) - ↓ -YieldController.setYieldMode(player, mode) - ├─ Stores mode in playerYieldMode map - ├─ Initializes tracking (turn number, phase, etc.) - └─ Calls callback.showPromptMessage("Yielding until...") - ↓ -CYield calls gameController.selectButtonOk() - ↓ -Priority is passed (network message if online) -``` - -**2. Auto-Yield Check (Game Loop):** -``` -Priority prompt would normally appear - ↓ -YieldController.shouldAutoYieldForPlayer(player) - ├─ Check if yield mode is active - ├─ Check interrupt conditions (attacks, targeting, mass removal, etc.) - ├─ Check mode-specific end conditions - └─ Return true/false - ↓ -If true: Automatically call selectButtonOk() (pass priority) -If false: Show priority prompt to user -``` - -**3. Interrupt Condition:** -``` -Game event occurs (e.g., player is attacked) - ↓ -YieldController.shouldInterruptYield(player) - ├─ Check preference settings - ├─ Check if condition affects this specific player - └─ Return true if should interrupt - ↓ -If true: YieldController.clearYieldMode(player) - ├─ Remove from all tracking maps - └─ Call callback.showPromptMessage("") - ↓ -User sees normal priority prompt -``` - -**4. Smart Suggestion Flow:** -``` -Priority prompt triggered - ↓ -InputPassPriority.showMessage() - ├─ Check if experimental yield enabled - ├─ Check if already yielding (skip if yes) - ├─ Check each suggestion condition (stack, no mana, no actions) - ├─ Check if suggestion was declined this turn - └─ Show suggestion or normal prompt - ↓ -User accepts suggestion: - ├─ Set yield mode - └─ Pass priority - ↓ -User declines suggestion: - ├─ Track decline in declinedSuggestionsThisTurn - └─ Show normal prompt -``` - -#### File Organization - -``` -forge-gui/ (shared GUI code) -├── YieldMode.java # Yield mode enum definitions -├── YieldController.java # Core yield logic and state management -├── AbstractGuiGame.java # Yield delegation and GUI integration -├── InputPassPriority.java # Smart suggestion prompts -├── IGuiGame.java # Interface with yield methods -├── IGameController.java # Controller interface (no yield-specific methods) -├── PlayerControllerHuman.java # Local game controller implementation -├── ForgePreferences.java # 13 new preferences -├── NetGameController.java # Network controller (no protocol changes) -└── en-US.properties # 30+ localization strings - -forge-gui-desktop/ (desktop-specific) -├── VYield.java # Yield Options panel view (NEW) -├── CYield.java # Yield Options panel controller (NEW) -├── VPrompt.java # Right-click menu on End Turn button -├── VMatchUI.java # Dynamic panel visibility based on preferences -├── CMatchUI.java # Yield panel registration and updates -├── GameMenu.java # Yield Options submenu with Display Options -└── KeyboardShortcuts.java # F-key shortcuts for yield modes - -forge-gui-desktop/res/layouts/ -└── match.xml # Added REPORT_YIELD to default layout -``` - -### Key Design Decisions - -1. **Feature-gated**: Master toggle prevents accidental activation; default OFF -2. **GUI layer only**: No changes to `forge-game` rules engine or network protocol -3. **Network independent**: Yield state is client-local; no synchronization needed -4. **Backward compatible**: Existing Ctrl+E behavior unchanged -5. **Individual toggles**: Each suggestion/interrupt can be configured separately -6. **PlayerView consistency**: All yield methods use `TrackableTypes.PlayerViewType.lookup(player)` to ensure Map key consistency and prevent instance mismatch bugs - -### End Turn Button Behavior - -The "End Turn" button (Cancel button during priority) has different behavior depending on whether experimental yields are enabled: - -**Legacy Mode (experimental yields OFF):** -- Uses `autoPassUntilEndOfTurn` system -- Cancelled when ANY opponent casts a spell or activates an ability (even if it doesn't affect you) -- Cancelled at cleanup phase for all players -- Good for 1v1 where you always want to respond to opponent actions - -**Experimental Mode (experimental yields ON):** -- Uses `YieldMode.UNTIL_END_OF_TURN` with smart interrupts -- Only interrupted based on your configured interrupt settings: - - When you're attacked (if enabled) - - When you or your permanents are targeted (if enabled) - - When opponents cast spells (if enabled) - excludes triggered abilities -- Better for multiplayer where you don't need to respond to actions between other players - -### State Management - -All yield state is managed by `YieldController` and accessed through `AbstractGuiGame`: - -```java -// In AbstractGuiGame.java -private YieldController yieldController; - -private YieldController getYieldController() { - if (yieldController == null) { - yieldController = new YieldController(new YieldController.YieldCallback() { - @Override - public void showPromptMessage(PlayerView player, String message) { - AbstractGuiGame.this.showPromptMessage(player, message); - } - @Override - public void updateButtons(PlayerView player, boolean ok, boolean cancel, boolean focusOk) { - AbstractGuiGame.this.updateButtons(player, ok, cancel, focusOk); - } - @Override - public void awaitNextInput() { - AbstractGuiGame.this.awaitNextInput(); - } - @Override - public void cancelAwaitNextInput() { - AbstractGuiGame.this.cancelAwaitNextInput(); - } - @Override - public GameView getGameView() { - return AbstractGuiGame.this.getGameView(); - } - }); - } - return yieldController; -} - -// Delegation methods -public void setYieldMode(PlayerView player, YieldMode mode) { - getYieldController().setYieldMode(player, mode); -} -``` - -**YieldController Internal State Maps:** -```java -// In YieldController.java -private final Map playerYieldMode = Maps.newHashMap(); -private final Map yieldStartTurn = Maps.newHashMap(); -private final Map yieldCombatStartTurn = Maps.newHashMap(); -private final Map yieldCombatStartedAtOrAfterCombat = Maps.newHashMap(); -private final Map yieldEndStepStartTurn = Maps.newHashMap(); -private final Map yieldEndStepStartedAtOrAfterEndStep = Maps.newHashMap(); -private final Map yieldYourTurnStartedDuringOurTurn = Maps.newHashMap(); -private final Map yieldNextPhaseStartPhase = Maps.newHashMap(); - -// Smart suggestion decline tracking (resets each turn) -private final Map> declinedSuggestionsThisTurn = Maps.newHashMap(); -private final Map declinedSuggestionsTurn = Maps.newHashMap(); - -// Legacy auto-pass tracking (backward compatibility) -private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); -``` - -**Key Implementation Details:** - -1. **PlayerView Lookup**: All methods use `TrackableTypes.PlayerViewType.lookup(player)` to ensure map key consistency -2. **Callback Pattern**: YieldController uses callback interface to avoid direct GUI dependencies -3. **Lazy Initialization**: YieldController is created on first access to avoid overhead when feature is disabled -4. **Turn-Based Reset**: Declined suggestions automatically reset when turn number changes - -The `shouldAutoYieldForPlayer()` method evaluates: -1. Legacy auto-pass state (backward compatibility) -2. Current yield mode -3. Interrupt conditions (configured via preferences) -4. Mode-specific end conditions (see table below) - -**Mode-Specific End Conditions:** - -| Mode | Tracking State | End Condition Logic | -|------|----------------|---------------------| -| `UNTIL_NEXT_PHASE` | `yieldNextPhaseStartPhase` | Current phase ≠ start phase | -| `UNTIL_STACK_CLEARS` | None | Stack.isEmpty() && !hasSimultaneousStackEntries() | -| `UNTIL_END_OF_TURN` | `yieldStartTurn` | Current turn > start turn | -| `UNTIL_YOUR_NEXT_TURN` | `yieldYourTurnStartedDuringOurTurn` | Player becomes active player (with wrap-around logic) | -| `UNTIL_BEFORE_COMBAT` | `yieldCombatStartTurn`, `yieldCombatStartedAtOrAfterCombat` | Next COMBAT_BEGIN phase (skips current turn's combat if already passed) | -| `UNTIL_END_STEP` | `yieldEndStepStartTurn`, `yieldEndStepStartedAtOrAfterEndStep` | Next END_OF_TURN phase (skips current turn's end step if already passed) | - -## Files Changed - -### New Files (4) -- `forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java` - Yield mode enum -- `forge-gui/src/main/java/forge/gamemodes/match/YieldController.java` - Core yield logic and state management -- `forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java` - Yield panel view -- `forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java` - Yield panel controller - -### Modified Files (13) - -**forge-gui (8 files):** -- `AbstractGuiGame.java` - Yield controller delegation, callback implementation -- `InputPassPriority.java` - Smart suggestion prompts with decline tracking -- `IGuiGame.java` - Interface methods for yield operations -- `IGameController.java` - Controller interface (no yield-specific methods) -- `PlayerControllerHuman.java` - Controller implementation, reveal skip during yield -- `ForgePreferences.java` - 13 new preferences -- `NetGameController.java` - Controller interface implementation (no protocol changes) -- `en-US.properties` - 30+ localization strings - -**forge-gui-desktop (5 files):** -- `VPrompt.java` - Right-click menu on End Turn button, ESC key handler -- `VMatchUI.java` - Dynamic panel visibility based on preferences -- `CMatchUI.java` - Yield panel registration and updates -- `GameMenu.java` - Yield Options submenu with Display Options -- `KeyboardShortcuts.java` - F-key shortcuts for yield modes - -## New Preferences - -```java -// Master toggle -YIELD_EXPERIMENTAL_OPTIONS("false") - -// Smart suggestions -YIELD_SUGGEST_STACK_YIELD("true") -YIELD_SUGGEST_NO_MANA("true") -YIELD_SUGGEST_NO_ACTIONS("true") - -// Interrupt conditions -YIELD_INTERRUPT_ON_ATTACKERS("true") -YIELD_INTERRUPT_ON_BLOCKERS("true") -YIELD_INTERRUPT_ON_TARGETING("true") -YIELD_INTERRUPT_ON_OPPONENT_SPELL("false") -YIELD_INTERRUPT_ON_COMBAT("false") -YIELD_INTERRUPT_ON_REVEAL("false") // Also covers opponent choices -YIELD_INTERRUPT_ON_MASS_REMOVAL("true") // Board wipes, exile all, etc. - -// Display options -YIELD_SHOW_RIGHT_CLICK_MENU("false") // Right-click menu on End Turn button - -// Keyboard shortcuts (F-keys) -SHORTCUT_YIELD_UNTIL_NEXT_PHASE("112") // F1 -SHORTCUT_YIELD_UNTIL_BEFORE_COMBAT("113") // F2 -SHORTCUT_YIELD_UNTIL_END_STEP("114") // F3 -SHORTCUT_YIELD_UNTIL_END_OF_TURN("115") // F4 -SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN("116") // F5 -SHORTCUT_YIELD_UNTIL_STACK_CLEARS("117") // F6 -``` - -## Testing Guide - -### Prerequisites -1. Enable `YIELD_EXPERIMENTAL_OPTIONS` in preferences -2. Start a 3+ player game (for full feature testing) - -### Test Cases - -#### Master Toggle -- [ ] Feature OFF by default -- [ ] Right-click menu hidden when OFF -- [ ] Keyboard shortcuts inactive when OFF -- [ ] Existing Ctrl+E behavior unchanged when OFF - -#### Yield Modes -- [ ] Until Stack Clears - stops when stack empties -- [ ] Until End of Turn - stops at UNTAP phase of next turn (not cleanup) -- [ ] Until Your Next Turn - stops when YOU become active player -- [ ] Until Your Next Turn - only available in 3+ player games -- [ ] Yield modes do NOT persist after your turn completes - -#### Access Methods -- [ ] Right-click End Turn button shows popup menu -- [ ] Keyboard shortcuts trigger correct yield modes -- [ ] Menu options reflect player count (hide 3+ player options in 2-player) -- [ ] "End Turn" button (Cancel) uses experimental yield when feature enabled -- [ ] "End Turn" button uses legacy behavior when feature disabled - -#### Smart Suggestions -- [ ] Stack suggestion appears when player can't respond (in prompt area, not dialog) -- [ ] No-mana suggestion appears when cards in hand but no mana -- [ ] No-actions suggestion appears when no possible plays (checks actual playability) -- [ ] Suggestions don't appear on your own turn -- [ ] Suggestions don't appear if already yielding -- [ ] Each suggestion respects its individual toggle -- [ ] Accept button activates yield mode -- [ ] Decline button shows normal priority prompt -- [ ] **Declined suggestions are suppressed** - After declining, same suggestion type does NOT appear again on same turn -- [ ] **Suppression resets on turn change** - Declined suggestions can appear again on next turn -- [ ] **Hint text shown** - "(Declining disables this prompt until next turn)" appears in suggestion prompt -- [ ] **Yield buttons override suggestions** - Clicking a yield button while suggestion is showing activates the clicked yield, not the suggested one - -#### Interrupts -- [ ] Attackers declared against you cancels yield -- [ ] Attackers declared against OTHER players does NOT cancel your yield (multiplayer) -- [ ] Blockers phase cancels yield only when creatures are attacking YOU -- [ ] Being targeted (you or your permanents) cancels yield -- [ ] Spells targeting other players does NOT cancel your yield -- [ ] "Opponent spell" only triggers for spells and activated abilities, NOT triggered abilities - - Triggered abilities that target you are handled by the "targeting" interrupt instead -- [ ] Reveal dialogs interrupt yield when "Interrupt on Reveal/Choices" is ON -- [ ] Reveal dialogs auto-dismissed when "Interrupt on Reveal/Choices" is OFF (default) -- [ ] Opponent choice notifications (e.g., Unclaimed Territory) auto-dismissed when setting is OFF -- [ ] Each interrupt respects its toggle setting - -#### Visual Feedback -- [ ] Prompt area shows "Yielding until..." message -- [ ] Cancel button allows breaking out of yield -- [ ] Yield Options submenu checkboxes stay open when toggled (menu doesn't close) -- [ ] Yield Options panel appears as tab with Stack panel -- [ ] Active yield button highlighted in red, others blue -- [ ] Yield buttons disabled during mulligan/pre-game phases -- [ ] Yield buttons disabled during cleanup/discard phase -- [ ] "Clear Stack" button disabled when stack is empty - -#### Network Play -- [ ] Yield modes work correctly in network games (each client manages its own yield state) -- [ ] No desync when one player uses extended yields (yield is client-local) - -## Troubleshooting - -### Yield Not Working - -**Yield doesn't activate when clicking button:** -- Verify `YIELD_EXPERIMENTAL_OPTIONS` is set to `true` in preferences -- Restart Forge after changing the preference -- Yield buttons are disabled during mulligan, pre-game, and cleanup phases - -**Yield clears unexpectedly:** -- Check interrupt settings in Forge > Game > Yield Options > Interrupt Settings -- If being attacked or targeted, yield will clear (if those interrupts are enabled) -- Yield modes clear automatically when their end condition is met - -**Smart suggestions not appearing:** -- Verify individual suggestion preferences are enabled -- Suggestions don't appear if you're already yielding -- If you declined a suggestion, it won't appear again until next turn -- Suggestions only appear when experimental yields are enabled - -### Network Play Issues - -**Yield behaves differently for different players:** -- This is expected - each client manages its own yield state -- Yield preferences are client-local, not synchronized -- Each player sees their own yield settings - -**Desync concerns:** -- Yield system cannot cause desync - it's GUI-only -- Network protocol is unchanged -- Server only sees standard priority pass messages - -### Performance - -**Game feels slow when yielding:** -- This is normal - the game loop checks yield conditions on each priority check -- Performance impact is minimal (Map lookups and boolean checks) -- Consider disabling interrupt conditions you don't need to simplify checks - -## Risk Assessment - -### Low Risk -- Feature-gated with default OFF -- No changes to game rules or logic -- No changes to network protocol or synchronization -- GUI layer changes only - game rules unaffected -- Existing behavior unchanged when feature disabled - -### Considerations -- **Mobile**: Changes are desktop-only (VPrompt, GameMenu, KeyboardShortcuts) -- **Preferences**: New preferences added; old preference files compatible - -## Changelog - -### 2026-01-31 - Network-Safe GameView Refactor - -**Problem:** Non-host players in multiplayer experienced freezing and yield malfunctions. The yield system was using `gameView.getGame()` which returns a transient `Game` object that is not serialized over the network. For non-host clients, this returned a dummy local `Game` instance with no actual state. - -**Solution:** Comprehensive refactoring of all network-unsafe code in both `YieldController` and `InputPassPriority` to use network-synchronized TrackableProperties and View classes exclusively. - -**Core Changes:** - -| Component | Before | After | -|-----------|--------|-------| -| Phase tracking | `game.getPhaseHandler().getPhase()` | `gameView.getPhase()` | -| Turn tracking | `game.getPhaseHandler().getTurn()` | `gameView.getTurn()` | -| Current player | `game.getPhaseHandler().getPlayerTurn()` | `gameView.getPlayerTurn()` | -| Stack access | `game.getStack()` | `gameView.getStack()` | -| Combat access | `game.getCombat()` | `gameView.getCombat()` | -| Player lookup | `game.getPlayer(playerView)` | Direct `PlayerView` comparison | -| Player actions check | `player.getCardsIn().getAllPossibleAbilities()` | `playerView.hasAvailableActions()` | -| Mana loss check | `player.getManaPool().willManaBeLostAtEndOfPhase()` | `playerView.willLoseManaAtEndOfPhase()` | -| Mana availability | `player.getManaPool().totalMana()` | `playerView.getMana()` + battlefield scan | -| Hand contents | `player.getCardsIn(ZoneType.Hand)` | `playerView.getHand()` | -| Battlefield | `player.getCardsIn(ZoneType.Battlefield)` | `playerView.getBattlefield()` | - -**New TrackableProperties:** -- `TrackableProperty.HasAvailableActions` - Whether player has playable spells/abilities -- `TrackableProperty.WillLoseManaAtEndOfPhase` - Whether floating mana will be lost -- `TrackableProperty.ApiType` - Spell API type for mass removal detection - -**New PlayerView Methods:** -- `hasAvailableActions()` - Network-safe check for available actions -- `willLoseManaAtEndOfPhase()` - Network-safe mana loss warning - -**New Player Methods:** -- `hasAvailableActions()` - Checks hand and battlefield for playable abilities -- `updateAvailableActionsForView()` - Updates the view property - -**Update Call Sites:** -- `Player.updateManaForView()` - Now also updates `WillLoseManaAtEndOfPhase` -- `PhaseHandler.passPriority()` - Now updates `HasAvailableActions` for priority player - -**InputPassPriority Refactoring:** -- `getGameView()` / `getPlayerView()` - New helper methods for view access -- `getDefaultYieldMode()` - Now uses `gameView.getPlayers().size()` -- `shouldShowStackYieldPrompt()` - Uses `gameView.getStack()` and `playerView.hasAvailableActions()` -- `shouldShowNoManaPrompt()` - Uses `gameView.getStack()`, `gameView.getPlayerTurn()`, `playerView.getHand()`, `hasManaAvailable(PlayerView)` -- `hasManaAvailable(PlayerView)` - Replaced `Player` version with view-based implementation -- `shouldShowNoActionsPrompt()` - Uses view properties exclusively -- `passPriority()` - Uses `playerView.willLoseManaAtEndOfPhase()` for mana warning - -**YieldController Refactoring:** -- `setYieldMode()` - Phase/turn tracking now uses GameView -- `shouldAutoYieldForPlayer()` - All yield termination checks use GameView -- `shouldInterruptYield()` - Uses CombatView, StackItemView, PlayerView -- `isBeingAttacked()` - Refactored to use CombatView instead of Combat -- `targetsPlayerOrPermanents()` - Uses PlayerView directly -- `hasMassRemovalOnStack()` - Uses StackItemView.getApiType() -- `getPlayerCount()` - Uses gameView.getPlayers() -- `declineSuggestion()` / `isSuggestionDeclined()` - Uses gameView.getTurn() - -**Bug Fix - Suggestions appearing after yield ends:** -- **Problem:** Smart suggestions (e.g., "no mana available") would appear immediately after a yield ended, even though the player had just been yielding. This occurred because `shouldAutoYieldForPlayer()` would clear the yield mode before `showMessage()` ran, so `isAlreadyYielding()` returned false. -- **Solution:** Added `yieldJustEnded` tracking set in YieldController. When a yield ends due to an end condition or interrupt, the player is added to this set. `InputPassPriority.showMessage()` now checks `didYieldJustEnd()` (which clears the flag) and skips suggestions if true. -- **Files:** `YieldController.java`, `IGuiGame.java`, `AbstractGuiGame.java`, `InputPassPriority.java` - -**Bug Fix - Wrong yield mode active after clicking yield button:** -- **Problem:** On network clients, clicking a yield button (e.g., "Combat") would highlight correctly but the actual behavior would be UNTIL_END_OF_TURN instead of the selected mode. This was caused by two issues: - 1. The legacy `autoPassUntilEndOfTurn` set wasn't being cleared when setting an experimental yield mode - 2. The `autoPassUntilEndOfTurn()` and `autoPassCancel()` methods were missing the PlayerView lookup, causing set membership mismatches -- **Solution:** - 1. Added `autoPassUntilEndOfTurn.remove(player)` at the start of `setYieldMode()` when experimental yields are enabled - 2. Added `TrackableTypes.PlayerViewType.lookup(player)` to `autoPassUntilEndOfTurn()` and `autoPassCancel()` methods -- **Files:** `YieldController.java` - -**Bug Fix - Yield mode not working on network clients:** -- **Problem:** Network clients could set yield mode locally (button highlighted correctly), but the server didn't know about it. When priority passed back to the client, the server would check yield state on its own `NetGuiGame` instance which had no knowledge of the client's yield settings, resulting in smart suggestions being shown despite yielding. -- **Root Cause:** Yield state was stored client-side only. The client's `CMatchUI.setYieldMode()` updated its local `YieldController`, but the server's `NetGuiGame` (which handles priority logic for remote players) had its own separate `YieldController` that was never updated. -- **Solution:** Added network protocol support for yield mode synchronization: - 1. Added `notifyYieldModeChanged(PlayerView, YieldMode)` to `IGameController` interface with default no-op implementation - 2. Added `notifyYieldModeChanged` to `ProtocolMethod` enum (CLIENT -> SERVER) - 3. Implemented in `NetGameController` to send yield changes to server - 4. Implemented in `PlayerControllerHuman` to receive and update server's GUI state - 5. Added `setYieldModeFromRemote()` to `IGuiGame`/`AbstractGuiGame` to update yield without triggering notification loop - 6. Modified `AbstractGuiGame.setYieldMode()` to call `notifyYieldModeChanged()` on the game controller -- **Files:** `IGameController.java`, `ProtocolMethod.java`, `NetGameController.java`, `PlayerControllerHuman.java`, `IGuiGame.java`, `AbstractGuiGame.java` - -**Bug Fix - Yield button stays highlighted after yield ends on network client:** -- **Problem:** When a yield mode ended due to its end condition (e.g., "yield until next turn" expires when turn changes), the yield button on the client remained highlighted even though the yield had stopped. -- **Root Cause:** The server's YieldController detected the end condition and cleared the yield mode, but this wasn't synchronized back to the client. The client's local YieldController still thought the yield was active, keeping the button highlighted. -- **Solution:** Added server→client yield state synchronization: - 1. Added `syncYieldMode` to `ProtocolMethod` enum (SERVER -> CLIENT) - 2. Added `syncYieldMode(PlayerView, YieldMode)` to `IGuiGame` interface - 3. Implemented in `NetGuiGame` to send yield state to client - 4. Implemented in `AbstractGuiGame` to receive and update local state - 5. Added `syncYieldModeToClient` to `YieldCallback` interface - 6. Modified `YieldController.clearYieldMode()` to call the callback, notifying the client -- **Files:** `ProtocolMethod.java`, `IGuiGame.java`, `NetGuiGame.java`, `AbstractGuiGame.java`, `YieldController.java` - -**Bug Fix - Wrong prompt shown after setting yield on network client:** -- **Problem:** Client set "End Step" yield (button correctly highlighted in red), but prompt showed "Yielding until end of turn" text. -- **Root Cause:** When client set yield mode, `AbstractGuiGame.setYieldMode()` showed the correct prompt locally, then notified the server. The server's `setYieldModeFromRemote()` was calling `updateAutoPassPrompt()` which sent another prompt back to the client, overwriting the correct one. Due to timing or state differences, the server sent the wrong message. -- **Solution:** Removed `updateAutoPassPrompt()` call from `setYieldModeFromRemote()` since the client already showed the correct prompt when it set the yield mode locally. -- **Files:** `AbstractGuiGame.java` - -**Bug Fix - Network PlayerView tracker mismatch causing yield lookup failures:** -- **Problem:** Yield mode set by client wasn't being found when server checked `mayAutoPass()`. -- **Root Cause:** Network-deserialized PlayerViews have a different `Tracker` instance than the server's PlayerViews. When `notifyYieldModeChanged` stored the yield mode using the network PlayerView's tracker, the `TrackableTypes.PlayerViewType.lookup()` later failed because the server's `mayAutoPass()` used a different PlayerView instance with a different tracker. -- **Solution:** Added `lookupPlayerViewById()` helper method that finds the matching PlayerView from `GameView.getPlayers()` by ID comparison, ensuring yield mode is stored against the server's canonical PlayerView instance. -- **Files:** `AbstractGuiGame.java` - -### Initial Implementation - YieldController Architecture - -**Core Design:** -1. **YieldController class** - Separated yield logic from AbstractGuiGame using delegate pattern -2. **YieldCallback interface** - Decoupled yield logic from GUI implementation for testability -3. **PlayerView lookup** - Used `TrackableTypes.PlayerViewType.lookup()` throughout for Map key consistency -4. **State tracking maps** - Separate maps for different yield modes' timing requirements - -**Design Pattern Rationale:** -- Delegate pattern allows AbstractGuiGame to remain focused on GUI coordination -- Callback interface enables testing without full GUI stack -- Lazy initialization avoids overhead when feature is disabled - -### 2026-01-30 - Yield Until Next Phase & Dynamic Hotkeys - -**New Feature:** -1. **Yield Until Next Phase** - New yield mode that automatically passes priority until the next phase begins. This is a simple, predictable yield that clears on any phase transition. - -2. **Dynamic Hotkey Display** - All hotkey references in button tooltips and yield prompt messages now dynamically update based on user preferences instead of showing hardcoded values. If a user changes their keyboard shortcuts, the UI will reflect the new bindings. - -**Button Layout Change:** -- Row 1: Next Phase, Combat, End Step -- Row 2: End Turn, Your Turn, Clear Stack - -**Hotkey Reorder (defaults):** -- F1: Next Phase (new) -- F2: Combat -- F3: End Step -- F4: End Turn -- F5: Your Turn -- F6: Clear Stack - -**Files Changed:** -- `YieldMode.java` - Added `UNTIL_NEXT_PHASE` enum value -- `YieldController.java` - Added `yieldNextPhaseStartPhase` tracking, setYieldMode/shouldAutoYield/clearYieldMode logic, `getCancelShortcutDisplayText()` method -- `VYield.java` - Added btnNextPhase button, reordered layout, `updateTooltips()` method with dynamic shortcut text, `getShortcutDisplayText()` utility -- `CYield.java` - Added actNextPhase action listener, yieldUntilNextPhase method, highlight logic -- `KeyboardShortcuts.java` - Added actYieldUntilNextPhase action, reordered shortcut list -- `ForgePreferences.java` - Added SHORTCUT_YIELD_UNTIL_NEXT_PHASE, reordered F-key assignments -- `en-US.properties` - Added localization strings, updated tooltips and prompts to use `{0}` placeholder for dynamic hotkeys - -### 2026-01-30 - Mass Removal Interrupt Option - -**New Feature:** -1. **Mass removal spell interrupt** - New interrupt option that triggers when an opponent casts a mass removal spell that could affect your permanents (default: ON). Detects: - - `DestroyAll` - Wrath of God, Day of Judgment, Damnation - - `ChangeZoneAll` (exile/graveyard) - Farewell, Merciless Eviction - - `DamageAll` - Blasphemous Act, Chain Reaction - - `SacrificeAll` - All Is Dust, Bane of Progress - - The interrupt only triggers if you have permanents matching the spell's filter - empty board = no interrupt. - -**Files Changed:** -- `ForgePreferences.java` - Added `YIELD_INTERRUPT_ON_MASS_REMOVAL` preference -- `en-US.properties` - Added localization string -- `GameMenu.java` - Added menu checkbox -- `AbstractGuiGame.java` - Added detection logic (`hasMassRemovalOnStack`, `isMassRemovalSpell`, `checkSingleAbilityForMassRemoval`, `playerHasMatchingPermanents`) - -### 2026-01-29 - Auto-Suppress Suggestions & Bug Fixes - -**New Features:** -1. **Auto-suppress declined suggestions** - When a smart yield suggestion is declined, that suggestion type is automatically suppressed for the rest of the turn. At turn change, suppression resets. A hint is now shown: "(Declining disables this prompt until next turn)" - -2. **Yield button priority over suggestions** - Clicking a yield button while a smart suggestion is showing now properly activates the selected yield mode instead of the suggested one. - -3. **Extended reveal interrupt** - The "interrupt on reveal" setting now also covers opponent choices (e.g., Unclaimed Territory creature type selection). Label updated to "When cards revealed or choices made". - -4. **Yield buttons disabled during discard** - Yield buttons are now greyed out and disabled during the cleanup/discard phase, similar to mulligan. - -**Bug Fixes:** -1. **PlayerView instance matching** - Added `TrackableTypes.PlayerViewType.lookup(player)` to all yield-related methods (`setYieldMode`, `clearYieldMode`, `getYieldMode`, `shouldAutoYieldForPlayer`, `declineSuggestion`, `isSuggestionDeclined`). This fixes potential map key mismatches that could cause yield modes to not be tracked correctly. - -2. **Combat interrupt scoping** - Added null check for player lookup and improved `isBeingAttacked()` helper that checks if the player OR their planeswalkers/battles are being attacked. This prevents interrupts when other players are attacked in multiplayer. - -3. **Default for reveal interrupt** - Changed `YIELD_INTERRUPT_ON_REVEAL` default from `true` to `false` to reduce interruptions. - -**Technical Changes:** -- Added `declineSuggestion()` and `isSuggestionDeclined()` methods to `IGuiGame` interface and `AbstractGuiGame` -- Added `declinedSuggestionsThisTurn` and `declinedSuggestionsTurn` tracking maps -- Added `pendingSuggestionType` field to `InputPassPriority` -- Added yield check to `notifyOfValue()` in `PlayerControllerHuman` -- Added cleanup phase check to `canYieldNow()` in `CYield` - -### 2026-01-29 - Yield Options Panel & Reveal Interrupt Setting - -**New Features:** -1. **Yield Options Panel** - A dedicated dockable panel with yield control buttons: - - Appears as a tab alongside the Stack panel when experimental yields are enabled - - Contains buttons: Clear Stack, Combat, End Step, End Turn, Your Turn - - Buttons use highlight mode: blue (normal), red (active yield mode) - - "Your Turn" button only visible in 3+ player games - - "Clear Stack" only enabled when stack has items - - All buttons disabled during mulligan and pre-game phases - -2. **Interrupt on Reveal setting** - New interrupt option under Yield Options > Interrupt Settings: - - "When cards are revealed" (default: ON) - - When disabled, reveal dialogs are auto-dismissed during active yield - - Useful for avoiding interrupts when opponents tutor or reveal cards - -3. **Display Options submenu** - New submenu under Yield Options: - - "Show Right-Click Menu" - Toggle right-click yield menu on End Turn button (default: OFF) - -**Technical Changes:** -1. **FButton highlight mode** - Added `setUseHighlightMode()` and `setHighlighted()` to FButton for inverted color scheme (blue default, red when active) - -2. **Combat yield tracking** - Fixed issue where clicking Combat during an existing combat phase would skip past the next combat. Now tracks turn number and whether yield started at/after combat. - -3. **Panel visibility** - Yield Options panel dynamically shown/hidden based on `YIELD_EXPERIMENTAL_OPTIONS` preference - -### 2026-01-29 - New Yield Modes and F-Key Hotkeys - -**New Features:** -1. **UNTIL_BEFORE_COMBAT mode** - Yield until entering the COMBAT_BEGIN phase. Useful for taking actions in main phase before combat. - -2. **UNTIL_END_STEP mode** - Yield until the END_OF_TURN or CLEANUP phase. Useful for end-of-turn effects. - -3. **F-key hotkeys** - Updated hotkey scheme (F2-F7 to avoid conflict with F1=Help): - - F2: Yield until next phase - - F3: Yield until before combat - - F4: Yield until end step - - F5: Yield until end of turn - - F6: Yield until your next turn - - F7: Yield until stack clears - - ESC: Cancel active yield - -**Bug Fixes:** -1. **Stack clears with simultaneous triggers** - UNTIL_STACK_CLEARS now checks `hasSimultaneousStackEntries()` in addition to `isEmpty()` to properly wait for all triggers to resolve. - -2. **End of turn on own turn** - UNTIL_END_OF_TURN no longer gets interrupted by YIELD_INTERRUPT_ON_COMBAT when it's the player's own turn, allowing the yield to continue through combat. - -### 2026-01-29 - End Turn Button Integration & Trigger Exclusion - -**Improvements:** -1. **End Turn button uses experimental yields** - When experimental yield options are enabled, the "End Turn" button now uses `YieldMode.UNTIL_END_OF_TURN` with smart interrupts instead of the legacy behavior that cancels on any opponent spell. - -2. **Opponent spell excludes triggers** - The "interrupt on opponent spell" setting now only triggers for spells and activated abilities, NOT triggered abilities. Triggered abilities that target you are handled by the "targeting" interrupt instead. This prevents unwanted interrupts from attack triggers when other players are attacked. - -3. **Menu consolidation** - When experimental yields are enabled, "Auto-Yields" menu item is moved inside the "Yield Options" submenu instead of being a separate item. When disabled, Auto-Yields appears in the main Game menu as before. - -4. **End of turn yield fix** - `UNTIL_END_OF_TURN` now tracks the turn number when the yield was set and clears when the turn number changes. This ensures phase stops on the next turn work correctly, since UNTAP/CLEANUP phases don't give priority. - -5. **Yield re-enable fix** - Fixed issue where accepting a yield suggestion after an interrupt would immediately clear the yield. If turn number wasn't tracked when yield was set, it's now tracked on first check. - -### 2026-01-28 - Multiplayer Fixes & Simplified Yield Logic - -**Bug Fixes:** -1. **Multiplayer interrupt scoping** - Attack/blocker interrupts now only trigger when the player specifically is being attacked, not when any player is attacked. Changed from `getDefenders().contains(p)` to `!getAttackersOf(p).isEmpty()`. - -2. **Yield continuation bug** - Fixed issue where yields would continue past the player's turn. Simplified logic to clear all yields when player's turn starts. - -3. **Separated yield mode end conditions**: - - `UNTIL_END_OF_TURN`: Clears when turn number changes (superseded by 2026-01-29 fix) - - `UNTIL_YOUR_NEXT_TURN`: Clears when player's specific turn starts - -4. **Smart suggestions re-prompting** - Added `isAlreadyYielding()` check to prevent re-prompting when already yielding. - -5. **Prompt integration** - Changed smart suggestions from modal dialogs to prompt area with Accept/Decline buttons. - -6. **Menu checkbox behavior** - Yield Options submenu checkboxes now stay open when clicked (custom `processMouseEvent` override). - -7. **No actions check** - Fixed `hasAvailableActions()` to check actual playability via `getAllPossibleAbilities()` instead of just checking hand size. - -8. **Keybind/menu priority pass** - Added `selectButtonOk()` call after setting yield mode to immediately pass priority. - -**Removed:** -- `yieldTurnNumber` map (turn tracking simplified) - -## Authorship - -All code in this PR was written by Claude AI (Anthropic) under human instruction and direction. The human collaborator provided requirements, design decisions, testing feedback, and iterative guidance throughout development. Claude AI implemented all code changes, documentation, and technical solutions. \ No newline at end of file From 8cdeacd4c995d0eac1f2265f6b077c0893dfc0b6 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 1 Feb 2026 19:10:29 +1030 Subject: [PATCH 30/33] Move yield computation to PlayerView and query controller for preferences Addresses PR #9643 feedback from tool4ever on commit cbcd373: 1. "the code should probably also be moved to the View classes" - Moved hasAvailableActions() computation from Player to PlayerView - PlayerView.updateHasAvailableActions() now contains full logic 2. "that commit did not clean up Player class" - Removed hasAvailableActions() and updateAvailableActionsForView() - Player.java no longer has yield-related methods 3. "we definitely don't want another field in GameRules class" - Removed trackAvailableActions field and accessors from GameRules - Removed setTrackAvailableActions() call from HostedMatch 4. "should just query the PlayerController" - Added shouldTrackAvailableActions() to PlayerController (returns false) - PlayerControllerHuman overrides to check YIELD_EXPERIMENTAL_OPTIONS - PhaseHandler queries controller, skipping AI players automatically This approach keeps GUI preferences out of the game engine, enables per-player preference checking, and maintains network compatibility. Co-Authored-By: Claude Opus 4.5 --- .../src/main/java/forge/game/GameRules.java | 10 ---- .../java/forge/game/phase/PhaseHandler.java | 10 ++-- .../main/java/forge/game/player/Player.java | 57 ------------------ .../forge/game/player/PlayerController.java | 9 +++ .../java/forge/game/player/PlayerView.java | 58 ++++++++++++++++++- .../forge/gamemodes/match/HostedMatch.java | 5 -- .../forge/player/PlayerControllerHuman.java | 5 ++ 7 files changed, 74 insertions(+), 80 deletions(-) diff --git a/forge-game/src/main/java/forge/game/GameRules.java b/forge-game/src/main/java/forge/game/GameRules.java index e33731cf72a..c38b6c113c9 100644 --- a/forge-game/src/main/java/forge/game/GameRules.java +++ b/forge-game/src/main/java/forge/game/GameRules.java @@ -17,9 +17,6 @@ public class GameRules { private final Set appliedVariants = EnumSet.noneOf(GameType.class); private int simTimeout = 120; - // Whether to track available actions for yield suggestions (performance optimization) - private boolean trackAvailableActions = false; - // it's a preference, not rule... but I could hardly find a better place for it private boolean useGrayText; @@ -136,11 +133,4 @@ public int getSimTimeout() { public void setSimTimeout(final int duration) { this.simTimeout = duration; } - - public boolean tracksAvailableActions() { - return trackAvailableActions; - } - public void setTrackAvailableActions(boolean track) { - this.trackAvailableActions = track; - } } diff --git a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java index a625c46ee4e..c758daea3cd 100644 --- a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java +++ b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java @@ -1164,12 +1164,10 @@ else if (!game.getStack().hasSimultaneousStackEntries()) { p.setHasPriority(getPriorityPlayer() == p); } - // Update available actions for yield suggestions (only if tracking enabled) - if (game.getRules().tracksAvailableActions()) { - Player priorityPlayer = getPriorityPlayer(); - if (priorityPlayer != null) { - priorityPlayer.updateAvailableActionsForView(); - } + // Update available actions for yield suggestions (per-player, based on controller preference) + Player priorityPlayer = getPriorityPlayer(); + if (priorityPlayer != null && priorityPlayer.getController().shouldTrackAvailableActions()) { + priorityPlayer.getView().updateHasAvailableActions(priorityPlayer); } } diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index 075c389ae7a..5c6668b2f0d 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -1762,63 +1762,6 @@ public void updateManaForView() { view.updateWillLoseManaAtEndOfPhase(this); } - /** - * Check if this player has any available actions (playable spells/abilities). - * Used for smart yield suggestions in network play. - * - * Note: This uses a heuristic for mana checking since CostPartMana.canPay() - * always returns true. We estimate available mana from floating mana plus - * untapped mana sources and compare to spell CMCs. - */ - public boolean hasAvailableActions() { - // Estimate available mana: floating mana + untapped mana-producing permanents - int availableMana = getManaPool().totalMana(); - for (Card card : getCardsIn(ZoneType.Battlefield)) { - if (!card.isTapped() && !card.getManaAbilities().isEmpty()) { - // Count each untapped mana source as ~1 mana (simplified estimate) - availableMana++; - } - } - - // Check hand for playable spells that we can afford - for (Card card : getCardsIn(ZoneType.Hand)) { - for (SpellAbility sa : card.getAllPossibleAbilities(this, true)) { - // Check if this is a spell we could potentially afford - if (sa.isSpell()) { - int cmc = sa.getPayCosts().getTotalMana().getCMC(); - if (cmc <= availableMana) { - return true; - } - } else if (sa.isLandAbility()) { - // Land abilities are already filtered by canPlay() for timing - return true; - } - } - } - - // Check battlefield for non-mana activated abilities we can afford - for (Card card : getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility sa : card.getAllPossibleAbilities(this, true)) { - if (!sa.isManaAbility()) { - // Check if we can afford the activation cost - int activationCost = 0; - if (sa.getPayCosts() != null && sa.getPayCosts().hasManaCost()) { - activationCost = sa.getPayCosts().getTotalMana().getCMC(); - } - if (activationCost <= availableMana) { - return true; - } - } - } - } - - return false; - } - - public void updateAvailableActionsForView() { - view.updateHasAvailableActions(this); - } - public final int getNumPowerSurgeLands() { return numPowerSurgeLands; } diff --git a/forge-game/src/main/java/forge/game/player/PlayerController.java b/forge-game/src/main/java/forge/game/player/PlayerController.java index a03bc95d851..9b1eb194b77 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerController.java +++ b/forge-game/src/main/java/forge/game/player/PlayerController.java @@ -88,6 +88,15 @@ public boolean isAI() { return false; } + /** + * Whether to compute available actions for yield suggestions. + * Returns false by default (AI players, test controllers). + * Human players override to check preferences. + */ + public boolean shouldTrackAvailableActions() { + return false; + } + public Game getGame() { return gameView.getGame(); } public Match getMatch() { return gameView.getMatch(); } public Player getPlayer() { return player; } 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 cfd8d4200aa..8ac74758381 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -12,6 +12,7 @@ import forge.game.card.Card; import forge.game.card.CardView; import forge.game.card.CounterType; +import forge.game.spellability.SpellAbility; import forge.game.zone.PlayerZone; import forge.game.zone.ZoneType; import forge.trackable.TrackableCollection; @@ -560,8 +561,61 @@ public boolean hasAvailableActions() { Boolean val = get(TrackableProperty.HasAvailableActions); return val != null && val; } - void updateHasAvailableActions(Player p) { - set(TrackableProperty.HasAvailableActions, p.hasAvailableActions()); + + /** + * Check if this player has any available actions (playable spells/abilities). + * Used for smart yield suggestions in network play. + * + * Note: This uses a heuristic for mana checking since CostPartMana.canPay() + * always returns true. We estimate available mana from floating mana plus + * untapped mana sources and compare to spell CMCs. + */ + public void updateHasAvailableActions(Player p) { + // Estimate available mana: floating mana + untapped mana-producing permanents + int availableMana = p.getManaPool().totalMana(); + for (Card card : p.getCardsIn(ZoneType.Battlefield)) { + if (!card.isTapped() && !card.getManaAbilities().isEmpty()) { + // Count each untapped mana source as ~1 mana (simplified estimate) + availableMana++; + } + } + + // Check hand for playable spells that we can afford + for (Card card : p.getCardsIn(ZoneType.Hand)) { + for (SpellAbility sa : card.getAllPossibleAbilities(p, true)) { + // Check if this is a spell we could potentially afford + if (sa.isSpell()) { + int cmc = sa.getPayCosts().getTotalMana().getCMC(); + if (cmc <= availableMana) { + set(TrackableProperty.HasAvailableActions, true); + return; + } + } else if (sa.isLandAbility()) { + // Land abilities are already filtered by canPlay() for timing + set(TrackableProperty.HasAvailableActions, true); + return; + } + } + } + + // Check battlefield for non-mana activated abilities we can afford + for (Card card : p.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllPossibleAbilities(p, true)) { + if (!sa.isManaAbility()) { + // Check if we can afford the activation cost + int activationCost = 0; + if (sa.getPayCosts() != null && sa.getPayCosts().hasManaCost()) { + activationCost = sa.getPayCosts().getTotalMana().getCMC(); + } + if (activationCost <= availableMana) { + set(TrackableProperty.HasAvailableActions, true); + return; + } + } + } + } + + set(TrackableProperty.HasAvailableActions, false); } /** 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 78a0c340569..85f6db690e3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -76,11 +76,6 @@ private static GameRules getDefaultRules(final GameType gameType) { gameRules.setOrderCombatants(FModel.getPreferences().getPrefBoolean(FPref.LEGACY_ORDER_COMBATANTS)); gameRules.setUseGrayText(FModel.getPreferences().getPrefBoolean(FPref.UI_GRAY_INACTIVE_TEXT)); gameRules.setGamesPerMatch(FModel.getPreferences().getPrefInt(FPref.UI_MATCHES_PER_GAME)); - // Enable available actions tracking when experimental yield features are on. - // Individual suggestion toggles control client display, not computation. - // Checking per-client preferences would require network protocol changes. - gameRules.setTrackAvailableActions( - FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)); // AI specific sideboarding rules switch (AiProfileUtil.getAISideboardingMode()) { case Off: diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index ba0934ee829..5e0f66285db 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -156,6 +156,11 @@ public void setMayLookAtAllCards(final boolean mayLookAtAllCards) { this.mayLookAtAllCards = mayLookAtAllCards; } + @Override + public boolean shouldTrackAvailableActions() { + return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + private final ArrayList tempShownCards = new ArrayList<>(); public void tempShow(final Iterable objects) { From c5b154d3678defb4aff0d61a72ba77b95928a90e Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Mon, 2 Feb 2026 07:32:47 +1030 Subject: [PATCH 31/33] Add preference guard to updateWillLoseManaAtEndOfPhase Only compute mana loss warnings when experimental yields are enabled, matching the pattern used for updateHasAvailableActions. This avoids unnecessary computation for AI players and when the feature is disabled. Co-Authored-By: Claude Opus 4.5 --- forge-game/src/main/java/forge/game/player/Player.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index 5c6668b2f0d..9b6b9b696d9 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -1759,7 +1759,9 @@ public final ManaPool getManaPool() { } public void updateManaForView() { view.updateMana(this); - view.updateWillLoseManaAtEndOfPhase(this); + if (getController().shouldTrackAvailableActions()) { + view.updateWillLoseManaAtEndOfPhase(this); + } } public final int getNumPowerSurgeLands() { From 11e15bea1eaa5458baad4de87e5a19ca8bafc6a7 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sat, 7 Feb 2026 18:55:35 +1030 Subject: [PATCH 32/33] Remove right-click yield menu and simplify yield button layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-click popup menu on the End Turn button is now redundant — the dedicated VYield button panel provides the same functionality. Remove the menu, its preference toggle, Display Options submenu, and associated localization strings. Also remove multiplayer-conditional layout logic so the Your Turn button is always shown. In 2-player games it still serves as a convenient "yield until my next turn" shortcut, skipping the opponent's entire turn without needing to hold priority through each phase. Co-Authored-By: Claude Opus 4.6 --- .../screens/match/controllers/CYield.java | 17 ---- .../forge/screens/match/menus/GameMenu.java | 5 -- .../forge/screens/match/views/VPrompt.java | 88 ------------------- .../forge/screens/match/views/VYield.java | 11 +-- forge-gui/res/languages/en-US.properties | 10 +-- .../properties/ForgePreferences.java | 1 - 6 files changed, 5 insertions(+), 127 deletions(-) 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 index 6de858bee57..382f193440c 100644 --- 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 @@ -41,9 +41,6 @@ public class CYield implements ICDoc { private final CMatchUI matchUI; private final VYield view; - // Cache multiplayer state (doesn't change during game) - private boolean isMultiplayer = false; - // Yield button action listeners private final ActionListener actNextPhase = evt -> yieldUntilNextPhase(); private final ActionListener actClearStack = evt -> yieldUntilStackClears(); @@ -65,23 +62,12 @@ public final VYield getView() { return view; } - /** - * Returns true if this is a multiplayer game (3+ players). - * Used by VYield to adjust layout for the "Your Turn" button. - */ - public boolean isMultiplayer() { - return isMultiplayer; - } - @Override public void register() { } @Override public void initialize() { - // Cache multiplayer state once - isMultiplayer = matchUI.getPlayerCount() >= 3; - // Initialize button action listeners initButton(view.getBtnNextPhase(), actNextPhase); initButton(view.getBtnClearStack(), actClearStack); @@ -156,9 +142,6 @@ public void updateYieldButtons() { && !matchUI.getGameView().getStack().isEmpty(); view.getBtnClearStack().setEnabled(canYield && stackHasItems); - // Show/hide Your Turn based on player count (only for 3+ players) - view.getBtnYourTurn().setVisible(isMultiplayer); - // Highlight active yield button updateActiveYieldHighlight(); } 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 b926bce9a69..7f537feafa1 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 @@ -239,11 +239,6 @@ private JMenu getYieldOptionsMenu() { suggestionsMenu.add(createYieldCheckbox(localizer.getMessage("lblSuppressAfterYield"), FPref.YIELD_SUPPRESS_AFTER_END)); yieldMenu.add(suggestionsMenu); - // Sub-menu 3: Display Options - final JMenu displayMenu = new JMenu(localizer.getMessage("lblDisplayOptions")); - displayMenu.add(createYieldCheckbox(localizer.getMessage("lblShowRightClickMenu"), FPref.YIELD_SHOW_RIGHT_CLICK_MENU)); - yieldMenu.add(displayMenu); - return yieldMenu; } 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 5d0c88ee987..10437ef1ea4 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 @@ -25,12 +25,9 @@ import java.awt.event.MouseEvent; import javax.swing.JLabel; -import javax.swing.JMenuItem; import javax.swing.JPanel; -import javax.swing.JPopupMenu; import javax.swing.ScrollPaneConstants; import javax.swing.SwingConstants; -import javax.swing.SwingUtilities; import forge.game.card.CardView; import forge.game.player.PlayerView; @@ -119,17 +116,6 @@ public VPrompt(final CPrompt controller) { btnOK.addKeyListener(buttonKeyAdapter); btnCancel.addKeyListener(buttonKeyAdapter); - // Add right-click menu for yield options (experimental feature) - btnCancel.addMouseListener(new MouseAdapter() { - @Override - public void mousePressed(MouseEvent e) { - if (SwingUtilities.isRightMouseButton(e) && isYieldExperimentalEnabled() - && FModel.getPreferences().getPrefBoolean(FPref.YIELD_SHOW_RIGHT_CLICK_MENU)) { - showYieldOptionsMenu(e); - } - } - }); - tarMessage.setForeground(FSkin.getColor(FSkin.Colors.CLR_TEXT)); tarMessage.setMargin(new Insets(3, 3, 3, 3)); tarMessage.getAccessibleContext().setAccessibleName("Prompt"); @@ -236,78 +222,4 @@ public JLabel getLblGames() { return this.lblGames; } - // Yield options menu support (experimental feature) - - private boolean isYieldExperimentalEnabled() { - return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); - } - - private void showYieldOptionsMenu(MouseEvent e) { - JPopupMenu menu = new JPopupMenu(); - Localizer loc = Localizer.getInstance(); - - // Until Stack Clears - JMenuItem stackItem = new JMenuItem(loc.getMessage("lblYieldUntilStackClears")); - stackItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { - controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().selectButtonOk(); - } - } - }); - menu.add(stackItem); - - // Until End of Turn - JMenuItem turnItem = new JMenuItem(loc.getMessage("lblYieldUntilEndOfTurn")); - turnItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { - controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().selectButtonOk(); - } - } - }); - menu.add(turnItem); - - // Until Combat - JMenuItem combatItem = new JMenuItem(loc.getMessage("lblYieldUntilBeforeCombat")); - combatItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { - controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().selectButtonOk(); - } - } - }); - menu.add(combatItem); - - // Until End Step - JMenuItem endStepItem = new JMenuItem(loc.getMessage("lblYieldUntilEndStep")); - endStepItem.addActionListener(evt -> { - if (controller.getMatchUI() != null && controller.getMatchUI().getCurrentPlayer() != null) { - controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_END_STEP); - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().selectButtonOk(); - } - } - }); - menu.add(endStepItem); - - // Until Your Next Turn (only in 3+ player games) - if (controller.getMatchUI() != null && controller.getMatchUI().getPlayerCount() >= 3) { - JMenuItem yourNextTurnItem = new JMenuItem(loc.getMessage("lblYieldUntilYourNextTurn")); - yourNextTurnItem.addActionListener(evt -> { - if (controller.getMatchUI().getCurrentPlayer() != null) { - controller.getMatchUI().setYieldMode(controller.getMatchUI().getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); - if (controller.getMatchUI().getGameController() != null) { - controller.getMatchUI().getGameController().selectButtonOk(); - } - } - }); - menu.add(yourNextTurnItem); - } - - menu.show(btnCancel, e.getX(), e.getY()); - } } 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 index c4ac4fae31d..0cfa16cefad 100644 --- 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 @@ -120,15 +120,10 @@ public void populate() { container.add(btnCombat, buttonConstraints); container.add(btnEndStep, buttonConstraints); - // Row 2: End Turn, [Your Turn if multiplayer], Clear Stack + // Row 2: End Turn, Your Turn, Clear Stack container.add(btnEndTurn, buttonConstraints); - if (controller.isMultiplayer()) { - container.add(btnYourTurn, buttonConstraints); - container.add(btnClearStack, buttonConstraints); - } else { - // In 2-player games, Clear Stack moves to middle position - container.add(btnClearStack, buttonConstraints); - } + container.add(btnYourTurn, buttonConstraints); + container.add(btnClearStack, buttonConstraints); } @Override diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index c0d94c41528..fed4d8236e6 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1524,16 +1524,11 @@ lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action ({0}). lblYieldingUntilNextPhase=Yielding until next phase.\nYou may cancel this yield to take an action ({0}). cbYieldExperimentalOptions=Experimental: Enable expanded yield options -nlYieldExperimentalOptions=Adds extended yield options: yield until stack clears, yield until your next turn, smart yield suggestions, and interrupt configuration. Access via right-click on End Turn button. Options in Game toolbar. Requires restart. +nlYieldExperimentalOptions=Adds extended yield options: yield until stack clears, yield until your next turn, smart yield suggestions, and interrupt configuration. Options in Game toolbar. Requires restart. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action ({0}). lblYieldingUntilYourNextTurn=Yielding until your next turn.\nYou may cancel this yield to take an action ({0}). -lblYieldUntilStackClears=Yield Until Stack Clears -lblYieldUntilEndOfTurn=Yield Until End of Turn -lblYieldUntilYourNextTurn=Yield Until Your Next Turn lblYieldingUntilBeforeCombat=Yielding until combat.\nYou may cancel this yield to take an action ({0}). -lblYieldUntilBeforeCombat=Yield Until Combat lblYieldingUntilEndStep=Yielding until end step.\nYou may cancel this yield to take an action ({0}). -lblYieldUntilEndStep=Yield Until End Step lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? lblNoManaAvailableYieldPrompt=You have no mana available. Yield until your turn? lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? @@ -1556,8 +1551,7 @@ lblSuggestNoMana=When no mana available lblSuggestNoActions=When no actions available lblSuppressOnOwnTurn=Suppress On Own Turn lblSuppressAfterYield=Suppress After Yield Ends -lblDisplayOptions=Display Options -lblShowRightClickMenu=Show Right-Click Menu + lblYieldBtnNextPhase=Next Phase lblYieldBtnClearStack=Clear Stack lblYieldBtnCombat=Combat 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 92511b1f42f..a3306a0f910 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -147,7 +147,6 @@ public enum FPref implements PreferencesStore.IPref { YIELD_INTERRUPT_ON_COMBAT("false"), YIELD_INTERRUPT_ON_REVEAL("false"), // When opponent reveals cards YIELD_INTERRUPT_ON_MASS_REMOVAL("true"), // When mass removal spell cast - YIELD_SHOW_RIGHT_CLICK_MENU("false"), // Show right-click yield menu on End Turn YIELD_SUPPRESS_ON_OWN_TURN("true"), // Suppress suggestions on player's own turn YIELD_SUPPRESS_AFTER_END("true"), // Suppress suggestions for one priority pass after yield ends From 8057365b56b4d05ebb3dd2955ed15cc9854044c6 Mon Sep 17 00:00:00 2001 From: MostCromulent Date: Sun, 8 Feb 2026 09:22:36 +1030 Subject: [PATCH 33/33] Clean up yield rework: remove dead code and fix shortcut filter Remove unused isInLegacyAutoPass() method from YieldController. Fix VSubmenuPreferences to hide all yield shortcuts (not just 2 of 7) when experimental yield options are disabled. Co-Authored-By: Claude Opus 4.6 --- .../forge/screens/home/settings/VSubmenuPreferences.java | 3 +-- .../main/java/forge/gamemodes/match/YieldController.java | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index 67fef6c7bf3..5a29b9c3091 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -475,8 +475,7 @@ public enum VSubmenuPreferences implements IVSubmenu { for (final Shortcut s : shortcuts) { // Skip yield shortcuts if experimental options not enabled - if (!yieldExperimentalEnabled && (s.getPrefKey() == FPref.SHORTCUT_YIELD_UNTIL_STACK_CLEARS - || s.getPrefKey() == FPref.SHORTCUT_YIELD_UNTIL_YOUR_NEXT_TURN)) { + if (!yieldExperimentalEnabled && s.getPrefKey().name().startsWith("SHORTCUT_YIELD_")) { continue; } pnlPrefs.add(new FLabel.Builder().text(s.getDescription()) 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 e549135c761..507d120399c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -747,13 +747,6 @@ public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { return declined != null && declined.contains(suggestionType); } - /** - * Check if the legacy auto-pass is in the set (for AbstractGuiGame internal use). - */ - public boolean isInLegacyAutoPass(PlayerView player) { - return autoPassUntilEndOfTurn.contains(player); - } - /** * Remove a player from legacy auto-pass (for AbstractGuiGame internal use). */