diff --git a/docs/Expanded-Yield-Options.md b/docs/Expanded-Yield-Options.md new file mode 100644 index 00000000000..8c1f13201b8 --- /dev/null +++ b/docs/Expanded-Yield-Options.md @@ -0,0 +1,112 @@ +# 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 | 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. + +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. + +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. +- **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". + + + +## 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 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..c758daea3cd 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 yield suggestions (per-player, based on controller preference) + Player priorityPlayer = getPriorityPlayer(); + if (priorityPlayer != null && priorityPlayer.getController().shouldTrackAvailableActions()) { + priorityPlayer.getView().updateHasAvailableActions(priorityPlayer); + } } 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 59b363aec98..06f5c479a26 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,9 @@ public final ManaPool getManaPool() { } public void updateManaForView() { view.updateMana(this); + if (getController().shouldTrackAvailableActions()) { + view.updateWillLoseManaAtEndOfPhase(this); + } } public final int getNumPowerSurgeLands() { 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 fda59ebb712..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; @@ -556,6 +557,98 @@ void updateMana(Player p) { set(TrackableProperty.Mana, mana); } + public boolean hasAvailableActions() { + Boolean val = get(TrackableProperty.HasAvailableActions); + return val != null && val; + } + + /** + * 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); + } + + /** + * 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; + } + 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/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..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), @@ -275,6 +277,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-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 8b2de20b8de..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,6 +114,106 @@ 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 || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_STACK_CLEARS); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + }; + + /** 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 || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + if (matchUI.getPlayerCount() >= 3) { + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_YOUR_NEXT_TURN); + if (matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + } + }; + + /** 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 || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_END_OF_TURN); + if (matchUI.getGameController() != null) { + 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 || matchUI.getCurrentPlayer() == null) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + matchUI.setYieldMode(matchUI.getCurrentPlayer(), YieldMode.UNTIL_BEFORE_COMBAT); + if (matchUI.getGameController() != null) { + 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 || 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; } + YieldMode currentYield = matchUI.getYieldMode(matchUI.getCurrentPlayer()); + if (currentYield != null && currentYield != YieldMode.NONE) { + matchUI.clearYieldMode(matchUI.getCurrentPlayer()); + } + } + }; + /** Alpha Strike. */ final Action actAllAttack = new AbstractAction() { @Override @@ -208,6 +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_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/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/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/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 7daa2778b8c..c29e2e5dcc3 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 @@ -73,6 +73,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")); @@ -296,6 +297,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); @@ -479,8 +483,13 @@ 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().name().startsWith("SHORTCUT_YIELD_")) { + 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); @@ -997,6 +1006,10 @@ public final JCheckBox getCbManaLostPrompt() { return cbManaLostPrompt; } + public final JCheckBox getCbYieldExperimentalOptions() { + return cbYieldExperimentalOptions; + } + public final JCheckBox getCbDetailedPaymentDesc() { return cbDetailedPaymentDesc; } 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 843d7494e3b..e3721986b8d 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..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 @@ -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,31 @@ 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 || + !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); + } + } + //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..382f193440c --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java @@ -0,0 +1,213 @@ +/* + * 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; + + // 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(); + 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() { + // Initialize button action listeners + initButton(view.getBtnNextPhase(), actNextPhase); + 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(); + } + + /** + * 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(); + } + } + } + + // 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. + * 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.getBtnNextPhase().setEnabled(canYield); + 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); + + // 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.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); + 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 3d7db593366..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 @@ -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; @@ -49,7 +50,11 @@ 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()); menu.addSeparator(); @@ -204,4 +209,57 @@ 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")); + + // 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)); + 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)); + 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)); + 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); + + return yieldMenu; + } + + private JCheckBoxMenuItem createYieldCheckbox(String label, FPref pref) { + // 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()); + 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..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 @@ -30,6 +30,8 @@ import javax.swing.SwingConstants; 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; @@ -65,7 +67,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 ; @@ -75,6 +77,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(); @@ -205,4 +221,5 @@ public FHtmlViewer getTarMessage() { public JLabel getLblGames() { return this.lblGames; } + } 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..0cfa16cefad --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java @@ -0,0 +1,161 @@ +/* + * 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.gamemodes.match.YieldController; +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; +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 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")); + 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(); + btnNextPhase.setFont(smallFont); + 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 + btnNextPhase.setUseHighlightMode(true); + btnClearStack.setUseHighlightMode(true); + btnCombat.setUseHighlightMode(true); + btnEndStep.setUseHighlightMode(true); + btnEndTurn.setUseHighlightMode(true); + btnYourTurn.setUseHighlightMode(true); + + // 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)))); + } + + private String getShortcutDisplayText(String codeString) { + return YieldController.formatShortcutDisplayText(codeString); + } + + @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, 3 on bottom + container.setLayout(new MigLayout("wrap 3, gap 2px!, insets 3px")); + + // 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, Clear Stack + container.add(btnEndTurn, buttonConstraints); + container.add(btnYourTurn, buttonConstraints); + container.add(btnClearStack, 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 getBtnNextPhase() { return btnNextPhase; } + 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 5dde6bb379b..504daadef7e 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1530,7 +1530,58 @@ lblWaitingForPlayer=Waiting for {0}... 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. 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}). +lblYieldingUntilBeforeCombat=Yielding until combat.\nYou may cancel this yield to take an action ({0}). +lblYieldingUntilEndStep=Yielding until end step.\nYou may cancel this yield to take an action ({0}). +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 +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 +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 +lblSuppressOnOwnTurn=Suppress On Own Turn +lblSuppressAfterYield=Suppress After Yield Ends + +lblYieldBtnNextPhase=Next Phase +lblYieldBtnClearStack=Clear Stack +lblYieldBtnCombat=Combat +lblYieldBtnEndStep=End Step +lblYieldBtnYourTurn=Your 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 ({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 6061f062e9d..9e1f3ae299c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -170,7 +170,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 @@ -427,7 +427,41 @@ 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; + + 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(); + } + @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; + } /** * Automatically pass priority until reaching the Cleanup phase of the @@ -435,26 +469,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) { - return autoPassUntilEndOfTurn.contains(player); + return getYieldController().mayAutoPass(player); } private Timer awaitNextInputTimer; @@ -583,13 +609,119 @@ 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); + getYieldController().updateAutoPassPrompt(getCurrentPlayer()); + } + + // Extended yield mode methods (experimental feature) + @Override + 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); + } + + @Override + public 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 + public final void clearYieldMode(PlayerView player) { + getYieldController().clearYieldMode(player); + } + + @Override + 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); + } + + @Override + public int getPlayerCount() { + return getYieldController().getPlayerCount(); + } + + @Override + public void declineSuggestion(PlayerView player, String suggestionType) { + getYieldController().declineSuggestion(player, suggestionType); + } + + @Override + public boolean isSuggestionDeclined(PlayerView player, String suggestionType) { + return getYieldController().isSuggestionDeclined(player, suggestionType); + } + // End auto-yield/input code // Abilities to auto-yield to 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..507d120399c --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -0,0 +1,787 @@ +/* + * 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(); + /** + * 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; + + // 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 yieldStates = Maps.newHashMap(); + + // Smart suggestion decline tracking (reset each turn) + 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 + */ + 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(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure consistent PlayerView instance + autoPassUntilEndOfTurn.add(player); + } + + /** + * Cancel auto-pass for the given player. + */ + public void autoPassCancel(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); // ensure consistent PlayerView instance + 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 + 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(); + 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; + } + + // Clear any legacy auto-pass state to prevent interference + // (legacy check in shouldAutoYieldForPlayer runs first and would override experimental mode) + autoPassUntilEndOfTurn.remove(player); + + YieldState state = new YieldState(mode); + yieldStates.put(player, state); + + 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 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; + } + } + + /** + * Clear yield mode for a player. + */ + 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 + yieldStates.put(player, new YieldState(mode)); + } + + /** + * Internal method to clear yield state without callbacks. + */ + private void clearYieldModeInternal(PlayerView player) { + yieldStates.remove(player); + autoPassUntilEndOfTurn.remove(player); // Legacy compatibility + } + + /** + * 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; + } + YieldState state = yieldStates.get(player); + return state != null && state.mode != null ? state.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. + */ + 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; + } + + YieldState state = yieldStates.get(player); + if (state == null || state.mode == null || state.mode == YieldMode.NONE) { + return false; + } + + // Check interrupt conditions + if (shouldInterruptYield(player)) { + clearYieldMode(player); + yieldJustEnded.add(player); // Track that yield just ended + return false; + } + + GameView gameView = callback.getGameView(); + if (gameView == null) { + return false; + } + + // 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 (state.mode) { + case UNTIL_NEXT_PHASE -> { + 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 != state.startPhase) { + clearYieldMode(player); + yieldJustEnded.add(player); + yield false; + } + yield true; + } + case UNTIL_STACK_CLEARS -> { + // Use GameView.getStack() which is network-synchronized + boolean stackEmpty = gameView.getStack() == null || gameView.getStack().isEmpty(); + if (stackEmpty) { + clearYieldMode(player); + yieldJustEnded.add(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 + if (state.startTurn == null) { + // Turn wasn't tracked when yield was set - track it now + state.startTurn = currentTurn; + yield true; + } + if (currentTurn > state.startTurn) { + clearYieldMode(player); + yieldJustEnded.add(player); + yield false; + } + yield true; + } + case UNTIL_YOUR_NEXT_TURN -> { + // Yield until our turn starts - use PlayerView comparison (network-safe) + boolean isOurTurn = currentPlayerTurn != null && currentPlayerTurn.equals(player); + + if (state.startedDuringOurTurn == null) { + // Tracking wasn't set - initialize it now + 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(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(state.startedDuringOurTurn)) { + // We've left our turn, now waiting for it to come back + state.startedDuringOurTurn = false; + } + } + yield true; + } + case UNTIL_BEFORE_COMBAT -> { + if (state.startTurn == null) { + // Tracking wasn't set - initialize it now + 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 + if (isAtOrAfterCombat(currentPhase)) { + boolean differentTurn = currentTurn > state.startTurn; + boolean sameTurnButStartedBeforeCombat = (currentTurn == state.startTurn.intValue()) && !Boolean.TRUE.equals(state.startedAtOrAfterPhase); + + if (differentTurn || sameTurnButStartedBeforeCombat) { + clearYieldMode(player); + yieldJustEnded.add(player); + yield false; + } + } + yield true; + } + case UNTIL_END_STEP -> { + if (state.startTurn == null) { + // Tracking wasn't set - initialize it now + 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 + if (isAtOrAfterEndStep(currentPhase)) { + boolean differentTurn = currentTurn > state.startTurn; + boolean sameTurnButStartedBeforeEndStep = (currentTurn == state.startTurn.intValue()) && !Boolean.TRUE.equals(state.startedAtOrAfterPhase); + + if (differentTurn || sameTurnButStartedBeforeEndStep) { + clearYieldMode(player); + yieldJustEnded.add(player); + yield false; + } + } + yield true; + } + default -> false; + }; + } + + /** + * 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) { + return false; + } + + ForgePreferences prefs = FModel.getPreferences(); + 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 && + combatView != null && isBeingAttacked(combatView, player)) { + 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 && + combatView != null && isBeingAttacked(combatView, player)) { + return true; + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TARGETING)) { + 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)) { + // 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; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_COMBAT)) { + if (phase == forge.game.phase.PhaseType.COMBAT_BEGIN) { + 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)) { + return true; + } + } + } + + if (prefs.getPrefBoolean(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { + // Use network-safe StackItemView.getApiType() for mass removal detection + if (hasMassRemovalOnStack(gameView, player)) { + return true; + } + } + + return false; + } + + /** + * Check if the player is being attacked (directly or via planeswalkers/battles). + * Uses network-safe CombatView instead of Combat. + */ + private boolean isBeingAttacked(forge.game.combat.CombatView combatView, PlayerView player) { + if (combatView == null) { + return false; + } + + // 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.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; + } + } + } + } + + return false; + } + + /** + * 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, PlayerView player) { + forge.util.collect.FCollectionView targetPlayers = si.getTargetPlayers(); + if (targetPlayers != null) { + for (PlayerView target : targetPlayers) { + if (target.equals(player)) 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, player)) { + return true; + } + + return false; + } + + /** + * Check if there's a mass removal spell on the stack that could affect the player's permanents. + * Uses network-safe StackItemView.getApiType() for detection. + * Only interrupts if the spell was cast by an opponent. + */ + 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.StackItemView si : stack) { + PlayerView activatingPlayer = si.getActivatingPlayer(); + + // Only interrupt for opponent's spells + if (activatingPlayer == null || activatingPlayer.equals(player)) { + continue; + } + + // Check if this is a mass removal spell type (including sub-instances) + if (isMassRemovalStackItem(si)) { + return true; + } + } + return false; + } + + /** + * Determine if a stack item is a mass removal effect. + * Recursively checks sub-instances for modal spells like Farewell. + */ + private boolean isMassRemovalStackItem(forge.game.spellability.StackItemView si) { + // Check the main ability + if (isMassRemovalApiType(si.getApiType())) { + return true; + } + + // 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 an API type name represents a mass removal effect. + */ + private boolean isMassRemovalApiType(String apiType) { + if (apiType == null) { + return false; + } + + // DestroyAll - Wrath of God, Day of Judgment, Damnation + // DamageAll - Blasphemous Act, Chain Reaction + // SacrificeAll - All Is Dust, Bane of Progress + // ChangeZoneAll - Farewell, Merciless Eviction (covers exile/bounce effects) + return "DestroyAll".equals(apiType) || + "DamageAll".equals(apiType) || + "SacrificeAll".equals(apiType) || + "ChangeZoneAll".equals(apiType); + } + + /** + * Check if experimental yield options are enabled in preferences. + */ + 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(). + */ + public int getPlayerCount() { + GameView gameView = callback.getGameView(); + 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) return; + + int currentTurn = gameView.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. + * 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) return false; + + int currentTurn = gameView.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); + } + + /** + * Remove a player from legacy auto-pass (for AbstractGuiGame internal use). + */ + public void removeFromLegacyAutoPass(PlayerView player) { + autoPassUntilEndOfTurn.remove(player); + } + + /** + * 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 static String formatShortcutDisplayText(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 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/YieldMode.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java new file mode 100644 index 00000000000..c01ed6a3733 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMode.java @@ -0,0 +1,42 @@ +/* + * 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_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"), + UNTIL_BEFORE_COMBAT("Yield until combat"), + UNTIL_END_STEP("Yield until end step"); + + 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..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 @@ -18,10 +18,17 @@ package forge.gamemodes.match.input; 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.gamemodes.match.YieldMode; +import forge.gui.GuiBase; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.GamePlayerUtil; @@ -29,6 +36,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; @@ -47,6 +55,11 @@ public class InputPassPriority extends InputSyncronizedBase { private List chosenSa; + // 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) { super(controller); } @@ -54,6 +67,78 @@ public InputPassPriority(final PlayerControllerHuman controller) { /** {@inheritDoc} */ @Override public final void showMessage() { + // Check if experimental yield features are enabled and show smart suggestions + // 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(); + + // Suggestion 1: Stack items but can't respond + 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() + && !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() + && !getController().getGui().isSuggestionDeclined(getOwner(), "NO_ACTIONS")) { + pendingSuggestion = getDefaultYieldMode(); + pendingSuggestionType = "NO_ACTIONS"; + pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + } + + showNormalPrompt(); + } + + 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); + chosenSa = null; + getController().getGui().updateButtons(getOwner(), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline"), + true, true, true); + getController().getGui().alertUser(); + } + + private void showNormalPrompt() { + pendingSuggestion = null; + pendingSuggestionType = null; + pendingSuggestionMessage = null; + showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); @@ -67,9 +152,36 @@ public final void showMessage() { 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 (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(); + return; + } + passPriority(() -> { getController().macros().addRememberedAction(new PassPriorityAction()); stop(); @@ -79,10 +191,29 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { + // 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; + } + 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(); }); } @@ -96,16 +227,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(); @@ -176,4 +316,98 @@ public boolean selectAbility(final SpellAbility ab) { } return false; } + + // 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); + } + + private GameView getGameView() { + return getController().getGui().getGameView(); + } + + private PlayerView getPlayerView() { + return getController().getGui().lookupPlayerViewById(getOwner()); + } + + private YieldMode getDefaultYieldMode() { + GameView gv = getGameView(); + return gv != null && gv.getPlayers().size() >= 3 + ? YieldMode.UNTIL_YOUR_NEXT_TURN + : YieldMode.UNTIL_END_OF_TURN; + } + + private boolean shouldShowStackYieldPrompt() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + + FCollectionView stack = gv.getStack(); + if (stack == null || stack.isEmpty()) { + return false; + } + + // Use TrackableProperty - player has no available actions + return !pv.hasAvailableActions(); + } + + /** + * Check if current game state is valid for showing yield suggestions. + * 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(); + 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() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + + if (!isValidSuggestionContext(gv, pv)) { + return false; + } + + FCollectionView hand = pv.getHand(); + if (hand == null || hand.isEmpty()) { + return false; + } + + return !pv.hasManaAvailable(); + } + + private boolean shouldShowNoActionsPrompt() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + + if (!isValidSuggestionContext(gv, pv)) { + return false; + } + + return !pv.hasAvailableActions(); + } } 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 b9378542fc0..f7936d836c1 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), showWaitingTimer (Mode.SERVER, Void.TYPE, PlayerView.class, String.class), // Client -> Server @@ -98,7 +101,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 ca1cc6c860a..8555f19b3fd 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 public void showWaitingTimer(final PlayerView forPlayer, final String waitingForPlayerName) { send(ProtocolMethod.showWaitingTimer, forPlayer, waitingForPlayerName); 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 dbcfe92ddc4..7105082aa2c 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,42 @@ public interface IGuiGame { void updateAutoPassPrompt(); + // 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); + + YieldMode getYieldMode(PlayerView player); + + boolean didYieldJustEnd(PlayerView player); + + 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); @@ -279,6 +316,14 @@ public interface IGuiGame { 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); + /** Signal to start a client-side elapsed timer for waiting display. */ void showWaitingTimer(PlayerView forPlayer, String waitingForPlayerName); 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/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index baf8d3a95ef..a3306a0f910 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,22 @@ 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"), + YIELD_INTERRUPT_ON_REVEAL("false"), // When opponent reveals cards + YIELD_INTERRUPT_ON_MASS_REMOVAL("true"), // When mass removal spell cast + 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"), UI_PAUSE_WHILE_MINIMIZED("false"), @@ -286,6 +302,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_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(""); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index a9d0d28701e..ed1c11f161a 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) { @@ -955,6 +960,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 +1770,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(); @@ -3255,6 +3285,10 @@ public void autoPassCancel() { getGui().autoPassCancel(getLocalPlayerView()); } + public int getPlayerCount() { + return getGui().getPlayerCount(); + } + @Override public void awaitNextInput() { getGui().awaitNextInput(); @@ -3290,6 +3324,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);