diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 105088fd83a..100927cf60c 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -6,6 +6,7 @@ - [AI](ai.md) - [Network Play](network-play.md) - [Advanced search](Advanced-Search.md) + - [Advanced Yield Options](advanced-yield-options.md) - Adventure Mode diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md new file mode 100644 index 00000000000..f6358228670 --- /dev/null +++ b/docs/advanced-yield-options.md @@ -0,0 +1,148 @@ +# Advanced 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. + +**Advanced Yield Options** is an experimental feature that significantly expands the legacy Forge auto-pass system through: + +- enabling players to automatically yield when there is no available action they can take. +- giving players the ability to yield until a specific phase is reached, without responding 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 **Yield Settings** dialog, 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 + +- **Anywhere:** open Gameplay Settings > Preferences > **Enable Advanced Yield Options**. +- **In a match (desktop):** open the Game menu > **Yield Options** > **Enable Advanced Yield Options**, or press the hotkey (default Ctrl+Y). +- **In a match (mobile):** open the in-match Game menu and toggle the same option from there. + +The change takes effect immediately — no restart required. + +## Once Enabled + +- The **Yield Options** panel (desktop) exposes the persistent **Auto-Pass** toggle and a **Settings** button that opens the Yield Settings dialog. +- On **mobile**, two equivalent entries appear in the in-match Game menu (below the existing **Auto-Yields** entry): **Yield Options** (opens the dialog) and **Auto-Pass: ON / OFF** (toggle). +- **Right-click** any phase indicator (desktop) or **long-press** it (mobile) to set a yield marker on that phase — see [Setting Yield Markers](#setting-yield-markers) below. +- Smart suggestions begin appearing in the prompt area (see [Automatic Yield Suggestions](#automatic-yield-suggestions)). + +## Auto-Pass + +**Auto-Pass** is a persistent toggle (F2 on desktop, or the Auto-Pass button) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. + +**How it works:** +- When enabled, Forge scans your hand, battlefield, and external zones (graveyard, exile, command) for castable spells, playable lands, and activatable abilities. +- If you have any available action, you keep priority as usual. +- If you have no available action, Forge passes priority on your behalf without prompting. +- The button label reflects the state (`Auto-Pass: ON` / `Auto-Pass: OFF`). + +**Interaction with interrupts:** Auto-Pass respects the interrupt settings in the Yield Settings dialog. Even if you have no actions, you will still be prompted when an interrupt condition fires — for example, when creatures attack you or when a mass-removal spell is cast. + +**Persistence:** Unlike yield markers, Auto-Pass does not end on a game event. It stays active until you toggle it off. + +**Performance and timeout:** The action-availability scan can be expensive in complex board states. The scan is subject to the **Auto-pass calculation timeout** setting in the Yield Settings dialog. On timeout the system prompts you instead of auto-passing, so a false positive means an extra prompt rather than a long stall. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). Set your own value in the Yield Settings dialog to override. + +> [!NOTE] +> **The Auto-pass AI is not perfect.** It is designed to avoid false negatives (passing priority when there is action you can take) as much as possible. There may be times it produces a false positive (giving you priority when there is nothing you can do). Use with appropriate caution. + +## Yield markers + +A **yield marker** tells Forge to auto-pass priority until a specific phase is reached. Markers are set directly on the phase indicator strip in the match UI. + +**Setting a marker:** +- **Desktop:** right-click the phase indicator cell for the phase you want to yield to. +- **Mobile:** long-press the phase indicator cell. + +A fast-forward symbol will appear on the targeted cell to show the marker is active. The prompt area also describes what phase you are yielding to. Forge then auto-passes priority on your behalf until that phase is reached, at which point the marker clears automatically and you regain priority. + +**Per-(player, phase) precision:** Each phase indicator cell is distinct per player. Right-clicking your own End Step yields to *your* End Step; right-clicking an opponent's End Step yields to *that opponent's* End Step. In multiplayer (e.g. four-player Commander) this lets you express things like "yield until that opponent's End Step" without affecting how you respond to the other opponents' end steps. + +**Cancelling:** +- Right-click (or long-press) the marker again to cancel it. +- Press **ESC** (desktop) to cancel any active marker. +- An enabled interrupt firing (see [Yield Interrupt Settings](#yield-interrupt-settings)) cancels the marker and hands priority back to you. + +**Re-targeting:** Right-clicking (or long-pressing) a different phase indicator while a marker is active moves the marker to the new cell. Only one marker is active at a time. + +**Setting a marker passes current priority** and starts auto-passing toward the marked phase. If you didn't want to pass priority, cancel with right-click/long-press or ESC. + +## Hotkeys (desktop) + +| Hotkey | Action | +|--------|--------| +| **F2** | Toggle Auto-Pass | +| **ESC** | Cancel any active yield marker or stack yield | +| **Ctrl+Y** | Toggle the **Advanced Yield Options** feature flag | + +All hotkeys can be modified from the in-game hotkeys menu (press H by default). Mobile uses Game-menu entries instead of hotkeys. + +## Yield Settings Menu + +The **Yield Settings** menu is the central configuration UI for advanced yield behavior. It's accessible from: +- **Desktop:** the Settings button on the Yield Options panel, or Game menu > **Yield Options** > **Yield Settings**. +- **Mobile:** Game menu > **Yield Options**. + +The dialog has four sections: + +### Yield Interrupt Settings + +Yield markers and stack-yield automatically cancel when important game events occur. Each interrupt can be individually toggled: + +| Interrupt | Default | Description | +|-----------|---------|-------------| +| **Attackers declared against you** | ON | Triggers when creatures attack you specifically (not when other players are attacked) | +| **You or your permanents targeted** | ON | Triggers when a spell/ability targets you or something you control | +| **Mass removal spell cast** | ON | Triggers when an opponent casts a board wipe or mass removal spell | +| **Opponent casts any spell** | OFF | Triggers on spells and activated abilities (not triggered abilities) | +| **Triggered abilities on stack** | OFF | Triggers when triggered abilities are on the stack | +| **Cards revealed or choices made** | OFF | Triggers when opponent reveal dialogs and choices are made | + +**Multiplayer note:** The attackers interrupt is scoped to you specifically. If Player A attacks Player B, your yield will not be interrupted. + +### Automatic Yield Suggestions + +When the system detects situations where you likely cannot take action, it prompts you with a yield suggestion in the prompt area, with Accept/Decline buttons. Each suggestion type has a dropdown controlling its decline behavior: + +| Suggestion | When it appears | Suggested action | Decline scope options | +|------------|-----------------|------------------|-----------------------| +| **Can't respond to stack** | You have no instant-speed responses available | Stack yield (auto-pass until stack empties) | Never / Always / Once per stack (default) / Once per turn | +| **No actions available** | No playable cards or activatable abilities (not your turn, stack empty) | Yield to your next turn | Never / Always / Once per turn (default) | + +**Decline scope options:** +- **Never:** suggestion is disabled entirely (never shown). +- **Always:** suggestion re-appears on the next priority pass, even if just declined. +- **Once per stack:** declining suppresses the suggestion until the current stack resolves. A new stack will re-prompt. (Only available for "Can't respond to stack".) +- **Once per turn:** declining suppresses the suggestion for the rest of the current turn. + +### Suppression Options + +- **Suppress on own turn:** by default, suggestions are suppressed on your own turn since you typically want to take actions during your turn. Suggestions are always suppressed on your first turn regardless of this setting, since you won't have any lands or mana yet. +- **Suppress immediately after yield ends:** by default, suggestions are suppressed for one priority pass when a yield expires or is interrupted, giving you time to assess the game state before deciding whether to re-yield. + +### Speed Options + +- **Auto-pass calculation timeout:** The amount of time in milliseconds the AI has to calculate whether you have any available actions and whether you should auto-pass. If the timeout is reached auto-pass will return false and hand you priority as a safeguard. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). +- **Skip delay between phases:** skip Forge's default 200ms delay between each phase resolving. +- **Skip delay when stack resolves:** skip Forge's default 400ms delay between items on the stack resolving. + +## Troubleshooting + +### Yield marker doesn't appear when right-clicking / long-pressing a phase indicator +- Verify **Advanced Yield Options** is enabled in preferences. +- Markers cannot be set during pre-game, mulligan, or cleanup phases. + +### Yield clears unexpectedly +- Check interrupt settings in the Yield Settings dialog. +- A marker also clears automatically the moment its target phase is reached. + +### Smart suggestions not appearing +- Verify the suggestion's decline scope is not set to "Never" in the Yield Settings dialog. +- Suggestions don't appear if you're already yielding. +- If you declined a suggestion, check the decline scope to understand when it will re-appear. +- Suggestions only appear when Advanced Yield Options are enabled. + +## Network Play + +- The host must have **Advanced Yield Options** enabled for clients to use them. If the host does not have the option enabled, a warning is posted in the chat window and the client's yield controls are disabled. +- Each player controls their own yield preferences. Your yield marker, stack-yield state, and interrupt settings apply to you only and take effect across the network — they do not affect other players. \ No newline at end of file diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java new file mode 100644 index 00000000000..0539743759e --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -0,0 +1,83 @@ +package forge.ai; + +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; +import org.tinylog.Logger; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +// Heuristic: does the player have any playable action this priority window? +// Bounded by timeoutMs; returns true on expiry (false-positive — player is prompted). +public final class AvailableActions { + + private static final Comparator BY_CMC_ASC = Comparator.comparingInt(Card::getCMC); + + private AvailableActions() {} + + public static boolean compute(Player player, long timeoutMs) { + long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; + + for (Card card : sortedCardsIn(player, ZoneType.Hand)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (sa.isSpell()) { + if (canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } else if (sa.isLandAbility()) { + return true; + } + } + } + + // Not sorted: activation costs are per-ability, not the permanent's CMC. + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } + } + + for (Card card : sortedCardsIn(player, ZoneType.Flashback)) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (checkTimeout(deadlineNanos, timeoutMs)) return true; + if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } + } + + return false; + } + + // Sort cheap cards first so cheap-to-validate matches early-exit + private static Iterable sortedCardsIn(Player player, ZoneType zone) { + Iterable cards = player.getCardsIn(zone); + List copy = new ArrayList<>(); + cards.forEach(copy::add); + if (copy.size() < 2) return copy; + copy.sort(BY_CMC_ASC); + return copy; + } + + private static boolean canAfford(SpellAbility sa, Player player) { + if (sa.getPayCosts() == null || !sa.getPayCosts().hasManaCost()) { + return true; + } + return ComputerUtilMana.canPayManaCost(sa, player, 0, false); + } + + private static boolean checkTimeout(long deadlineNanos, long timeoutMs) { + if (System.nanoTime() < deadlineNanos) { + return false; + } + Logger.warn("AvailableActions: heuristic timed out after {}ms; returning true.", timeoutMs); + return true; + } +} diff --git a/forge-game/src/main/java/forge/game/player/PlayerView.java b/forge-game/src/main/java/forge/game/player/PlayerView.java index daddba99a62..7198e09d27e 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -8,6 +8,7 @@ import forge.card.MagicColor; import forge.card.mana.ManaAtom; import forge.game.GameEntityView; +import forge.game.GameView; import forge.game.card.Card; import forge.game.card.CardView; import forge.game.card.CounterType; @@ -44,6 +45,24 @@ public static TrackableCollection getCollection(Iterable pla return collection; } + /** + * Look up a PlayerView by ID from the given GameView's player list. Used for + * network play where deserialized PlayerViews have different trackers than + * the host's GameView. Falls back to the input PlayerView if no match is + * found, or if the GameView is null. + */ + public static PlayerView findById(GameView gv, PlayerView player) { + if (player == null) return null; + if (gv == null) return player; + int id = player.getId(); + for (PlayerView pv : gv.getPlayers()) { + if (pv.getId() == id) { + return pv; + } + } + return player; + } + public PlayerView(final int id0, final Tracker tracker) { super(id0, tracker); @@ -522,6 +541,15 @@ void updateMana(Player p) { set(TrackableProperty.Mana, mana); } + public boolean hasAvailableActions() { + return get(TrackableProperty.HasAvailableActions); + } + + // Per-SA "playable" markers can be added as sibling properties without changing this signature. + public void setHasAvailableActions(boolean value) { + set(TrackableProperty.HasAvailableActions, value); + } + private List getDetailsList() { final List details = Lists.newArrayListWithCapacity(8); details.add(Localizer.getInstance().getMessage("lblLifeHas", getLife())); diff --git a/forge-game/src/main/java/forge/trackable/TrackableProperty.java b/forge-game/src/main/java/forge/trackable/TrackableProperty.java index b8c244139e1..06b3fc9fa7e 100644 --- a/forge-game/src/main/java/forge/trackable/TrackableProperty.java +++ b/forge-game/src/main/java/forge/trackable/TrackableProperty.java @@ -234,6 +234,7 @@ public enum TrackableProperty { HasPriority(TrackableTypes.BooleanType, FreezeMode.IgnoresFreeze), AvatarLifeDifference(TrackableTypes.IntegerType, FreezeMode.IgnoresFreeze), HasLost(TrackableTypes.BooleanType), + HasAvailableActions(TrackableTypes.BooleanType), //SpellAbility HostCard(TrackableTypes.CardViewType), diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 526d8d93d46..ba7370bf3b0 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -19,8 +19,11 @@ import forge.Singletons; import forge.game.spellability.StackItemView; +import forge.gamemodes.net.event.MessageEvent; +import forge.gamemodes.net.server.FServerManager; import forge.gui.framework.EDocID; import forge.gui.framework.SDisplayUtil; +import forge.interfaces.IGameController; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; @@ -131,6 +134,26 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Cancel any active yield (marker, stack-yield, auto-pass). */ + 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; } + IGameController ctrl = matchUI.getGameController(); + if (ctrl != null) { + if (ctrl.getYieldMarker() != null) { + ctrl.clearYieldMarker(); + } + if (ctrl.isStackYieldActive()) { + ctrl.setStackYield(false); + } + } + matchUI.getCYield().cancelAutoPassIfActive(); + } + }; + /** Alpha Strike. */ final Action actAllAttack = new AbstractAction() { @Override @@ -250,6 +273,46 @@ public void actionPerformed(final ActionEvent e) { } }; + /** Toggle yield options. */ + final Action actYieldOptions = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + final ForgePreferences prefs = FModel.getPreferences(); + final boolean newState = !prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + prefs.setPref(FPref.YIELD_EXPERIMENTAL_OPTIONS, newState); + prefs.save(); + if (matchUI != null) { + matchUI.refreshYieldPanel(); + } + final FServerManager server = FServerManager.getInstance(); + if (server != null && server.isHosting()) { + server.broadcast(new MessageEvent(Localizer.getInstance().getMessage( + newState ? "lblYieldHostEnabled" : "lblYieldHostToggleDisabled"))); + server.broadcastHostYieldEnabled(newState); + } + } + }; + + /** Toggle auto-pass when no actions. */ + final Action actAutoPassNoActions = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { return; } + final ForgePreferences prefs = FModel.getPreferences(); + final boolean newState = !prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + prefs.setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newState); + prefs.save(); + if (matchUI != null) { + matchUI.refreshYieldPanel(); + if (newState && matchUI.getGameController() != null) { + matchUI.getGameController().selectButtonOk(); + } + } + } + }; + /** Show keyboard shortcuts dialog. */ final Action actShowHotkeys = new AbstractAction() { @Override @@ -270,6 +333,9 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_UNDO, localizer.getMessage("lblSHORTCUT_UNDO"), actUndo, 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_OPTIONS, localizer.getMessage("lblSHORTCUT_YIELD_OPTIONS"), actYieldOptions, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_AUTO_PASS, localizer.getMessage("lblSHORTCUT_YIELD_AUTO_PASS"), actAutoPassNoActions, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_CANCEL, localizer.getMessage("lblSHORTCUT_YIELD_CANCEL"), actCancelYield, am, im)); list.add(new Shortcut(FPref.SHORTCUT_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 dabd0d5968e..72dcf8d68c7 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java @@ -94,6 +94,7 @@ public enum EDocID { REPORT_COMBAT (), REPORT_DEPENDENCIES (), REPORT_LOG (), + REPORT_YIELD (), DEV_MODE (), BUTTON_DOCK (), diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 5fbec6209ce..8efb3993228 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 @@ -158,6 +158,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 85fe017e108..eea2f6900a8 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 @@ -76,6 +76,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")); @@ -302,6 +303,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); @@ -489,8 +493,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); @@ -1017,6 +1026,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 167006c61b1..012d95ac891 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 @@ -51,6 +51,7 @@ import forge.deckchooser.FDeckViewer; import forge.game.GameEntityView; import forge.game.GameView; +import forge.interfaces.IGameController; import forge.game.card.Card; import forge.game.card.CardView; import forge.game.card.CardView.CardStateView; @@ -66,8 +67,9 @@ import forge.game.spellability.SpellAbilityView; import forge.game.spellability.StackItemView; import forge.game.zone.ZoneType; -import forge.util.IHasForgeLog; +import forge.gamemodes.match.YieldMarker; import forge.gamemodes.net.NetworkGuiGame; +import forge.util.IHasForgeLog; import forge.gui.FNetOverlay; import forge.gui.FThreads; import forge.gui.GuiBase; @@ -104,6 +106,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; @@ -171,6 +174,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() { @@ -190,6 +194,7 @@ public CMatchUI() { this.myDocs.put(EDocID.REPORT_COMBAT, cCombat.getView()); this.myDocs.put(EDocID.REPORT_DEPENDENCIES, cDependencies.getView()); this.myDocs.put(EDocID.REPORT_LOG, cLog.getView()); + this.myDocs.put(EDocID.REPORT_YIELD, getCYield().getView()); this.myDocs.put(EDocID.DEV_MODE, getCDev().getView()); this.myDocs.put(EDocID.BUTTON_DOCK, getCDock().getView()); } @@ -211,6 +216,15 @@ public boolean isCurrentScreen() { return Singletons.getControl().getCurrentScreen() == this.screen; } + /** Returns the CMatchUI controlling the currently active match screen, or null. */ + public static CMatchUI getActive() { + FScreen current = Singletons.getControl().getCurrentScreen(); + if (current == null || !current.isMatchScreen()) { + return null; + } + return current.getController() instanceof CMatchUI cm ? cm : null; + } + private boolean isInGame() { return getGameView() != null; } @@ -269,6 +283,9 @@ CPrompt getCPrompt() { public CStack getCStack() { return cStack; } + public CYield getCYield() { + return cYield; + } public TargetingOverlay getTargetingOverlay() { return targetingOverlay; } @@ -697,6 +714,57 @@ public void refreshLog() { cLog.getView().refreshDisplay(); } + public void refreshYieldPanel() { + view.populate(); + } + + // Whether the host has advanced yield options enabled (network play). + // Defaults to true so local games are unaffected. + private volatile boolean hostYieldEnabled = true; + + public boolean isHostYieldEnabled() { + return hostYieldEnabled; + } + + @Override + public void setHostYieldEnabled(boolean enabled) { + this.hostYieldEnabled = enabled; + FThreads.invokeInEdtNowOrLater(() -> getCYield().updateYieldButtons()); + } + + @Override + public void refreshYieldUi(final PlayerView player) { + FThreads.invokeInEdtNowOrLater(() -> { + // Marker is rendered only on the local player's view of the targeted phase indicator. + PlayerView local = getCurrentPlayer(); + if (local == null || !local.equals(player)) { + return; + } + for (final VField f : getFieldViews()) { + PhaseIndicator pi = f.getPhaseIndicator(); + if (pi == null) { + continue; + } + for (PhaseLabel l : pi.allLabels()) { + l.setYieldMarked(false); + } + } + IGameController controller = getGameController(); + YieldMarker marker = controller == null ? null : controller.getYieldMarker(); + if (marker == null) { + return; + } + VField markedField = getFieldViewFor(marker.getPhaseOwner()); + if (markedField == null) { + return; + } + PhaseLabel target = markedField.getPhaseIndicator().getLabelFor(marker.getPhase()); + if (target != null) { + target.setYieldMarked(true); + } + }); + } + public void repaintCardOverlays() { final List panels = getVisibleCardPanels(); for (final CardPanel panel : panels) { @@ -755,6 +823,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 @@ -867,7 +938,10 @@ public void finishGame() { @Override public void updateStack() { - FThreads.invokeInEdtNowOrLater(() -> getCStack().update()); + FThreads.invokeInEdtNowOrLater(() -> { + getCStack().update(); + getCYield().updateYieldButtons(); // Update yield button states + }); } /** @@ -1111,6 +1185,14 @@ public void openView(final TrackableCollection myPlayers) { FView.SINGLETON_INSTANCE.getPnlInsets().setForegroundImage(FSkin.getIcon(FSkinProp.BG_MATCH), true); else FView.SINGLETON_INSTANCE.getPnlInsets().setForegroundImage((Image)null); + + // If we're a network client, seed the host with our initial yield prefs. + if (myPlayers != null && !myPlayers.isEmpty()) { + final IGameController controller = getGameController(); + if (controller instanceof forge.gamemodes.net.client.NetGameController) { + controller.setYieldPrefs(forge.gamemodes.match.YieldPrefs.fromCurrentPreferences()); + } + } } /** 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 e66c80070c1..19064f39880 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 @@ -21,6 +21,7 @@ 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; @@ -158,6 +159,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/VYieldSettings.java b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java new file mode 100644 index 00000000000..ef19b11ab5c --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java @@ -0,0 +1,252 @@ +package forge.screens.match; + +import forge.Singletons; +import forge.gui.UiCommand; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.FButton; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FLabel; +import forge.toolbox.FTextField; +import forge.util.Localizer; +import forge.view.FDialog; + +import javax.swing.JSeparator; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; +import javax.swing.text.PlainDocument; + +/** + * Dialog for configuring yield interrupt conditions and automatic suggestions. + */ +@SuppressWarnings("serial") +public class VYieldSettings extends FDialog { + private static final int PADDING = 10; + private static final int ROW_HEIGHT = 24; + private static final int SECTION_GAP = 12; + private static final int BUTTON_WIDTH = 100; + private static final int BUTTON_HEIGHT = 26; + private static final int DROPDOWN_WIDTH = 120; + + private final CMatchUI matchUI; + + public VYieldSettings(CMatchUI matchUI) { + super(); + this.matchUI = matchUI; + final Localizer localizer = Localizer.getInstance(); + final ForgePreferences prefs = FModel.getPreferences(); + + setTitle(localizer.getMessage("lblYieldSettings")); + + int width = Math.min(Singletons.getView().getFrame().getWidth() * 2 / 3, 480); + int w = width - 2 * PADDING; + int x = PADDING; + int y = PADDING; + + // --- Interrupt Settings section --- + FLabel lblInterrupt = new FLabel.Builder().text(localizer.getMessage("lblInterruptSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblInterrupt, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT + 2; + + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTriggers"), FPref.YIELD_INTERRUPT_ON_TRIGGERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblAutoPassRespectsInterrupts"), FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, prefs); + + y += SECTION_GAP; + JSeparator sep = new JSeparator(); + add(sep, x, y, w, 2); + y += 2 + SECTION_GAP; + + // --- Automatic Suggestions section --- + FLabel lblSuggestions = new FLabel.Builder().text(localizer.getMessage("lblAutomaticSuggestions")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSuggestions, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT + 2; + + // Stack yield: label + dropdown (Never / Always / Once per stack / Once per turn) + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestStackYield"), + FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, + new String[] { + localizer.getMessage("lblDeclScopeNever"), + localizer.getMessage("lblDeclScopeAlways"), + localizer.getMessage("lblDeclScopeStack"), + localizer.getMessage("lblDeclScopeTurn") + }, + new String[] { "never", "always", "stack", "turn" }, + prefs); + + // No actions: label + dropdown (Never / Always / Once per turn) + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestNoActions"), + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, + new String[] { + localizer.getMessage("lblDeclScopeNever"), + localizer.getMessage("lblDeclScopeAlways"), + localizer.getMessage("lblDeclScopeTurn") + }, + new String[] { "never", "always", "turn" }, + prefs); + + y += SECTION_GAP; + + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressOnOwnTurn"), FPref.YIELD_SUPPRESS_ON_OWN_TURN, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressAfterYield"), FPref.YIELD_SUPPRESS_AFTER_END, prefs); + + y += SECTION_GAP; + JSeparator sep2 = new JSeparator(); + add(sep2, x, y, w, 2); + y += 2 + SECTION_GAP; + + // --- Speed Settings section --- + FLabel lblSpeed = new FLabel.Builder().text(localizer.getMessage("lblSpeedSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSpeed, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT + 2; + + y = addTimeoutField(x, y, w, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipPhaseDelay"), FPref.YIELD_SKIP_PHASE_DELAY, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipResolveDelay"), FPref.YIELD_SKIP_RESOLVE_DELAY, prefs); + + y += SECTION_GAP; + + // OK button + FButton btnOk = new FButton(localizer.getMessage("lblOK")); + btnOk.setCommand((UiCommand) () -> setVisible(false)); + int btnX = (width - BUTTON_WIDTH) / 2; + add(btnOk, btnX, y, BUTTON_WIDTH, BUTTON_HEIGHT); + y += BUTTON_HEIGHT + PADDING; + + this.pack(); + this.setSize(width, y + 3 * PADDING); + } + + private int addCheckbox(int x, int y, int w, String label, FPref pref, ForgePreferences prefs) { + FCheckBox cb = new FCheckBox(label, prefs.getPrefBoolean(pref)); + cb.addActionListener(e -> { + boolean value = cb.isSelected(); + prefs.setPref(pref, value); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) { + controller.setYieldInterruptPref(pref, value); + } + }); + add(cb, x, y, w, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private int addLabelWithDropdown(int x, int y, int w, String label, + FPref scopePref, String[] displayOptions, String[] valueOptions, + ForgePreferences prefs) { + // Label on left + int lblWidth = w - DROPDOWN_WIDTH - PADDING; + FLabel lbl = new FLabel.Builder().text(label).fontAlign(javax.swing.SwingConstants.LEFT).build(); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + // Dropdown on right (force fixed size so all dropdowns match) + FComboBox combo = new FComboBox<>(); + java.awt.Dimension dropSize = new java.awt.Dimension(DROPDOWN_WIDTH, ROW_HEIGHT); + combo.setPreferredSize(dropSize); + combo.setMinimumSize(dropSize); + combo.setMaximumSize(dropSize); + for (String opt : displayOptions) { + combo.addItem(opt); + } + // Select current value + String currentValue = prefs.getPref(scopePref); + for (int i = 0; i < valueOptions.length; i++) { + if (valueOptions[i].equals(currentValue)) { + combo.setSelectedIndex(i); + break; + } + } + combo.addActionListener(e -> { + int idx = combo.getSelectedIndex(); + if (idx >= 0 && idx < valueOptions.length) { + prefs.setPref(scopePref, valueOptions[idx]); + prefs.save(); + } + }); + add(combo, x + w - DROPDOWN_WIDTH, y, DROPDOWN_WIDTH, ROW_HEIGHT); + + return y + ROW_HEIGHT; + } + + private int addTimeoutField(int x, int y, int w, ForgePreferences prefs) { + final Localizer loc = Localizer.getInstance(); + final int fieldWidth = DROPDOWN_WIDTH; + int lblWidth = w - fieldWidth - PADDING; + String tooltip = loc.getMessage("lblAutoPassBudgetDesc"); + FLabel lbl = new FLabel.Builder().text(loc.getMessage("lblAutoPassBudgetLabel")) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + lbl.setToolTipText(tooltip); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + int current = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + FTextField field = new FTextField.Builder() + .ghostText(loc.getMessage("lblDynamic")) + .tooltip(tooltip) + .text(current > 0 ? String.valueOf(current) : "") + .build(); + ((PlainDocument) field.getDocument()).setDocumentFilter(new DigitsOnlyFilter(4)); + field.getDocument().addDocumentListener(new DocumentListener() { + private void save() { + String text = field.getText(); + int value = (text == null || text.isEmpty()) ? 0 : Integer.parseInt(text); + if (value > 9999) value = 9999; + prefs.setPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, String.valueOf(value)); + prefs.save(); + } + @Override public void insertUpdate(DocumentEvent e) { save(); } + @Override public void removeUpdate(DocumentEvent e) { save(); } + @Override public void changedUpdate(DocumentEvent e) { save(); } + }); + add(field, x + w - fieldWidth, y, fieldWidth, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private static final class DigitsOnlyFilter extends DocumentFilter { + private final int maxLength; + DigitsOnlyFilter(int maxLength) { this.maxLength = maxLength; } + @Override + public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException { + if (string == null) return; + String filtered = scrub(string); + if (filtered.isEmpty()) return; + if (fb.getDocument().getLength() + filtered.length() > maxLength) return; + super.insertString(fb, offset, filtered, attr); + } + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException { + String filtered = text == null ? "" : scrub(text); + int newLength = fb.getDocument().getLength() - length + filtered.length(); + if (newLength > maxLength) return; + super.replace(fb, offset, length, filtered, attrs); + } + private static String scrub(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isDigit(c)) sb.append(c); + } + return sb.toString(); + } + } + + public void showDialog() { + setVisible(true); + dispose(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java new file mode 100644 index 00000000000..183d39a9fbc --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CYield.java @@ -0,0 +1,153 @@ +/* + * 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.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.VYieldSettings; +import forge.screens.match.views.VYield; +import forge.util.Localizer; + +/** + * Controls the yield panel in the match UI. + * + *

(C at beginning of class name denotes a control class.) + */ +public class CYield implements ICDoc { + + private final CMatchUI matchUI; + private final VYield view; + + private final ActionListener actAutoPass = evt -> toggleAutoPass(); + private final ActionListener actSettings = evt -> openSettings(); + + public CYield(final CMatchUI matchUI) { + this.matchUI = matchUI; + this.view = new VYield(this); + } + + public final CMatchUI getMatchUI() { + return matchUI; + } + + public final VYield getView() { + return view; + } + + @Override + public void register() { + } + + @Override + public void initialize() { + initButton(view.getBtnAutoPass(), actAutoPass); + initButton(view.getBtnSettings(), actSettings); + + updateYieldButtons(); + } + + private void initButton(final JButton button, final ActionListener onClick) { + button.removeActionListener(onClick); + button.addActionListener(onClick); + } + + @Override + public void update() { + updateYieldButtons(); + } + + /** Disable auto-pass-no-actions if it's currently on. */ + public void cancelAutoPassIfActive() { + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + toggleAutoPass(); + } + } + + private void openSettings() { + new VYieldSettings(matchUI).showDialog(); + } + + private void toggleAutoPass() { + ForgePreferences prefs = FModel.getPreferences(); + boolean newState = !prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + prefs.setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newState); + prefs.save(); + updateYieldButtons(); + if (matchUI == null || matchUI.getGameController() == null) { + return; + } + matchUI.getGameController().setYieldInterruptPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newState); + if (newState) { + matchUI.getGameController().selectButtonOk(); + } + } + + public void updateYieldButtons() { + ForgePreferences prefs = FModel.getPreferences(); + + boolean yieldEnabled = prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS) + && matchUI.isHostYieldEnabled(); + boolean canYield = yieldEnabled && canYieldNow(); + + view.getBtnAutoPass().setEnabled(canYield); + + updateActiveYieldHighlight(); + } + + private void updateActiveYieldHighlight() { + boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + view.getBtnAutoPass().setHighlighted(autoPassOn); + view.getBtnAutoPass().setText(Localizer.getInstance().getMessage( + autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass")); + } + + /** False during mulligan, game-over, no-active-phase, or cleanup. */ + private boolean canYieldNow() { + GameView gameView = matchUI.getGameView(); + if (gameView == null) { + return false; + } + if (gameView.isGameOver()) { + return false; + } + if (gameView.isMulligan()) { + return false; + } + if (gameView.getTurn() < 1) { + return false; + } + if (gameView.getPhase() == null) { + return false; + } + if (gameView.getPhase() == forge.game.phase.PhaseType.CLEANUP) { + return false; + } + 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 5a3c2ffa77c..0b13db1f96f 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; import javax.swing.KeyStroke; @@ -11,6 +12,8 @@ import com.google.common.primitives.Ints; import forge.control.KeyboardShortcuts; +import forge.gamemodes.net.event.MessageEvent; +import forge.gamemodes.net.server.FServerManager; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; @@ -18,6 +21,7 @@ import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.screens.match.VAutoYields; +import forge.screens.match.VYieldSettings; import forge.screens.match.controllers.CDock.ArcState; import forge.toolbox.FSkin.SkinIcon; import forge.toolbox.FSkin.SkinnedMenu; @@ -50,7 +54,7 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); - menu.add(getMenuItem_AutoYields()); + menu.add(getYieldOptionsMenu()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); return menu; @@ -195,4 +199,44 @@ 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")); + final boolean yieldEnabled = prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + + // Auto-Yields (manage per-ability yields) - always available, independent of advanced options + yieldMenu.add(getMenuItem_AutoYields()); + yieldMenu.addSeparator(); + + // Enable Advanced Yield Options toggle with Ctrl+Y accelerator + final JCheckBoxMenuItem enableItem = new JCheckBoxMenuItem(localizer.getMessage("lblEnableAdvancedYieldOptions")); + final KeyStroke ks = KeyboardShortcuts.getKeyStrokeForPref(FPref.SHORTCUT_YIELD_OPTIONS); + if (ks != null) { enableItem.setAccelerator(ks); } + enableItem.setState(yieldEnabled); + + // Yield Settings dialog launcher (below the enable toggle) + final SkinnedMenuItem settingsItem = new SkinnedMenuItem(localizer.getMessage("lblYieldSettings")); + settingsItem.setEnabled(yieldEnabled); + settingsItem.addActionListener(e -> new VYieldSettings(matchUI).showDialog()); + + enableItem.addActionListener(e -> { + final boolean newState = !prefs.getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + prefs.setPref(FPref.YIELD_EXPERIMENTAL_OPTIONS, newState); + prefs.save(); + settingsItem.setEnabled(newState); + matchUI.refreshYieldPanel(); + final FServerManager server = FServerManager.getInstance(); + if (server != null && server.isHosting()) { + server.broadcast(new MessageEvent(localizer.getMessage( + newState ? "lblYieldHostEnabled" : "lblYieldHostToggleDisabled"))); + server.broadcastHostYieldEnabled(newState); + } + }); + yieldMenu.add(enableItem); + yieldMenu.add(settingsItem); + + return yieldMenu; + } + } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java index b463b915aed..7fbc4c98d22 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java @@ -99,6 +99,7 @@ public VField(final CMatchUI matchUI, final EDocID id0, final PlayerView p, fina this.docID = id0; this.player = p; + phaseIndicator.setOwner(p); if (p != null) { tab.setText(Localizer.getInstance().getMessage("lblPlayField", p.getName())); } else { tab.setText(Localizer.getInstance().getMessage("lblNoPlayerForEDocID", docID.toString())); } 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..fb7ed9ea235 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,7 @@ import javax.swing.SwingConstants; import forge.game.card.CardView; +import forge.interfaces.IGameController; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; @@ -75,6 +76,23 @@ public void setCardView(final CardView card) { @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + if (controller.getMatchUI() != null) { + IGameController ctrl = controller.getMatchUI().getGameController(); + if (ctrl != null) { + boolean cleared = false; + if (ctrl.getYieldMarker() != null) { + ctrl.clearYieldMarker(); + cleared = true; + } + if (ctrl.isStackYieldActive()) { + ctrl.setStackYield(false); + cleared = true; + } + if (cleared) return; + } + } + } if (btnCancel.isEnabled()) { if (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals("End Turn")) { btnCancel.doClick(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java index 6467ac8c23b..4acd98a67b2 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java @@ -18,6 +18,7 @@ package forge.screens.match.views; import java.awt.Color; +import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; @@ -27,6 +28,7 @@ import java.awt.image.BufferedImage; import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.ScrollPaneConstants; import javax.swing.SwingUtilities; @@ -36,6 +38,8 @@ import forge.game.GameView; import forge.game.card.CardView.CardStateView; import forge.game.spellability.StackItemView; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.gui.card.CardDetailUtil; import forge.gui.card.CardDetailUtil.DetailColors; import forge.gui.framework.DragCell; @@ -231,22 +235,30 @@ public void mouseClicked(final MouseEvent e) { } }); - if (item.isAbility()) { - addMouseListener(new FMouseAdapter() { - @Override - public void onLeftClick(final MouseEvent e) { - onClick(e); - } - @Override - public void onRightClick(final MouseEvent e) { - onClick(e); + addMouseListener(new FMouseAdapter() { + @Override + public void onLeftClick(final MouseEvent e) { + onClick(e); + } + @Override + public void onRightClick(final MouseEvent e) { + onClick(e); + } + private void onClick(final MouseEvent e) { + abilityMenu.setStackInstance(item); + boolean hasVisibleItem = false; + for (Component c : abilityMenu.getComponents()) { + if (c.isVisible()) { + hasVisibleItem = true; + break; + } } - private void onClick(final MouseEvent e) { - abilityMenu.setStackInstance(item); - abilityMenu.show(e.getComponent(), e.getX(), e.getY()); + if (!hasVisibleItem) { + return; } - }); - } + abilityMenu.show(e.getComponent(), e.getX(), e.getY()); + } + }); // TODO: A hacky workaround is currently used to make the game not leak the color information for Morph cards. final CardStateView curState = item.getSourceCard().getCurrentState(); @@ -284,6 +296,7 @@ private final class AbilityMenu extends JPopupMenu { private final JCheckBoxMenuItem jmiAutoYield; private final JCheckBoxMenuItem jmiAlwaysYes; private final JCheckBoxMenuItem jmiAlwaysNo; + private final JMenuItem jmiYieldToEntireStack; private StackItemView item; private Integer triggerID = 0; @@ -324,13 +337,22 @@ public AbilityMenu(){ } }); add(jmiAlwaysNo); + + jmiYieldToEntireStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToEntireStack")); + jmiYieldToEntireStack.addActionListener(arg0 -> { + controller.getMatchUI().getGameController().setStackYield(true); + controller.getMatchUI().getGameController().passPriority(); + }); + add(jmiYieldToEntireStack); } public void setStackInstance(final StackItemView item0) { item = item0; triggerID = item.getSourceTrigger(); - jmiAutoYield.setSelected(controller.getMatchUI().getGameController().shouldAutoYield(item.getKey())); + jmiAutoYield.setVisible(item.isAbility()); + jmiAutoYield.setSelected(item.isAbility() + && controller.getMatchUI().getGameController().shouldAutoYield(item.getKey())); if (item.isOptionalTrigger() && controller.getMatchUI().isLocalPlayer(item.getActivatingPlayer())) { jmiAlwaysYes.setSelected(controller.getMatchUI().getGameController().shouldAlwaysAcceptTrigger(triggerID)); @@ -341,6 +363,8 @@ public void setStackInstance(final StackItemView item0) { jmiAlwaysYes.setVisible(false); jmiAlwaysNo.setVisible(false); } + + jmiYieldToEntireStack.setVisible(FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)); } } } 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..75eec73e5fc --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VYield.java @@ -0,0 +1,105 @@ +/* + * Forge: Play Magic: the Gathering. + * Copyright (C) 2011 Forge Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package forge.screens.match.views; + +import javax.swing.JPanel; + +import forge.gui.framework.DragCell; +import forge.gui.framework.DragTab; +import forge.gui.framework.EDocID; +import forge.gui.framework.IVDoc; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.controllers.CYield; +import forge.toolbox.FButton; +import forge.toolbox.FSkin; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; + +/** + * Assembles Swing components of the yield controls panel. + * + *

(V at beginning of class name denotes a view class.) + */ +public class VYield implements IVDoc { + + // Fields used with interface IVDoc + private DragCell parentCell; + private final Localizer localizer = Localizer.getInstance(); + private final DragTab tab = new DragTab(localizer.getMessage("lblYieldOptions")); + + private final FButton btnAutoPass = new FButton(localizer.getMessage("lblYieldBtnAutoPass")); + private final FButton btnSettings = new FButton("..."); + + private final CYield controller; + + public VYield(final CYield controller) { + this.controller = controller; + + java.awt.Font smallFont = FSkin.getBoldFont(11).getBaseFont(); + btnAutoPass.setFont(smallFont); + btnSettings.setFont(smallFont); + + btnAutoPass.setUseHighlightMode(true); + btnSettings.setUseHighlightMode(true); + + btnAutoPass.setToolTipText(localizer.getMessage("lblYieldBtnAutoPassTooltip")); + btnSettings.setToolTipText(localizer.getMessage("lblInterruptSettingsTooltip")); + } + + @Override + public void populate() { + JPanel container = parentCell.getBody(); + + boolean largerButtons = FModel.getPreferences().getPrefBoolean(FPref.UI_FOR_TOUCHSCREN); + String heightConstraint = largerButtons ? "h 40px:40px:60px" : "hmin 20px"; + + container.setLayout(new MigLayout("gap 1px!, insets 2px, fillx")); + + container.add(btnAutoPass, "growx, pushx, w 83%, " + heightConstraint + ", gaptop 2px"); + container.add(btnSettings, "w 17%, " + heightConstraint + ", gaptop 2px"); + } + + @Override + public void setParentCell(final DragCell cell0) { + this.parentCell = cell0; + } + + @Override + public DragCell getParentCell() { + return this.parentCell; + } + + @Override + public EDocID getDocumentID() { + return EDocID.REPORT_YIELD; + } + + @Override + public DragTab getTabLabel() { + return tab; + } + + @Override + public CYield getLayoutControl() { + return controller; + } + + public FButton getBtnAutoPass() { return btnAutoPass; } + public FButton getBtnSettings() { return btnSettings; } +} diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java b/forge-gui-desktop/src/main/java/forge/toolbox/FButton.java index 3e7e7aa1781..604b00c2b63 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; + private boolean highlighted = false; private final AlphaComposite disabledComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.25f); private KeyAdapter klEnter; @@ -150,6 +152,23 @@ public void focusLost(final FocusEvent e) { } private void resetImg() { + if (useHighlightMode) { + // Stale hovered flag can persist across tab switches and window focus changes + if (hovered) { + imgL = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_LEFT); + imgM = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_CENTER); + imgR = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_RIGHT); + } else 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); + } + return; + } if (hovered) { imgL = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_LEFT); imgM = FSkin.getIcon(FSkinProp.IMG_BTN_OVER_CENTER); @@ -209,6 +228,48 @@ 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; + this.hovered = false; + if (isEnabled() && !isToggled()) { + resetImg(); + repaintSelf(); + } + } + public int getAutoSizeWidth() { int width = 0; if (this.getText() != null && !this.getText().isEmpty()) { diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java index f723d27260a..e29d614bb6a 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java @@ -1,31 +1,35 @@ package forge.toolbox.special; +import java.util.Arrays; +import java.util.List; + import javax.swing.JPanel; import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; import forge.util.Localizer; import net.miginfocom.swing.MigLayout; -/** +/** * TODO: Write javadoc for this type. * */ public class PhaseIndicator extends JPanel { private static final long serialVersionUID = -863730022835609252L; - + // Phase labels - private PhaseLabel lblUpkeep = new PhaseLabel("UP"); - private PhaseLabel lblDraw = new PhaseLabel("DR"); - private PhaseLabel lblMain1 = new PhaseLabel("M1"); - private PhaseLabel lblBeginCombat = new PhaseLabel("BC"); - private PhaseLabel lblDeclareAttackers = new PhaseLabel("DA"); - private PhaseLabel lblDeclareBlockers = new PhaseLabel("DB"); - private PhaseLabel lblFirstStrike = new PhaseLabel("FS"); - private PhaseLabel lblCombatDamage = new PhaseLabel("CD"); - private PhaseLabel lblEndCombat = new PhaseLabel("EC"); - private PhaseLabel lblMain2 = new PhaseLabel("M2"); - private PhaseLabel lblEndTurn = new PhaseLabel("ET"); - private PhaseLabel lblCleanup = new PhaseLabel("CL"); + private PhaseLabel lblUpkeep = new PhaseLabel("UP", PhaseType.UPKEEP); + private PhaseLabel lblDraw = new PhaseLabel("DR", PhaseType.DRAW); + private PhaseLabel lblMain1 = new PhaseLabel("M1", PhaseType.MAIN1); + private PhaseLabel lblBeginCombat = new PhaseLabel("BC", PhaseType.COMBAT_BEGIN); + private PhaseLabel lblDeclareAttackers = new PhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS); + private PhaseLabel lblDeclareBlockers = new PhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS); + private PhaseLabel lblFirstStrike = new PhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE); + private PhaseLabel lblCombatDamage = new PhaseLabel("CD", PhaseType.COMBAT_DAMAGE); + private PhaseLabel lblEndCombat = new PhaseLabel("EC", PhaseType.COMBAT_END); + private PhaseLabel lblMain2 = new PhaseLabel("M2", PhaseType.MAIN2); + private PhaseLabel lblEndTurn = new PhaseLabel("ET", PhaseType.END_OF_TURN); + private PhaseLabel lblCleanup = new PhaseLabel("CL", PhaseType.CLEANUP); public PhaseIndicator() { @@ -110,6 +114,20 @@ public PhaseLabel getLabelFor(final PhaseType s) { } } + /** Push the per-VField player binding to every label so right-click can route to it. */ + public void setOwner(final PlayerView player) { + for (PhaseLabel l : allLabels()) { + l.setPhaseOwner(player); + } + } + + public List allLabels() { + return Arrays.asList( + lblUpkeep, lblDraw, lblMain1, lblBeginCombat, lblDeclareAttackers, + lblDeclareBlockers, lblFirstStrike, lblCombatDamage, lblEndCombat, + lblMain2, lblEndTurn, lblCleanup); + } + /** * Resets all phase buttons to "inactive", so highlight won't be drawn on * them. "Enabled" state remains the same. diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java index 28f44c3de31..25d81fdf209 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java @@ -1,13 +1,24 @@ package forge.toolbox.special; +import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import javax.swing.JLabel; import javax.swing.SwingConstants; - +import javax.swing.SwingUtilities; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMarker; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.CMatchUI; import forge.toolbox.FSkin; /** @@ -17,28 +28,31 @@ */ @SuppressWarnings("serial") public class PhaseLabel extends JLabel { + /** Tint used when a yield marker is targeted at this (player, phase) cell. */ + private static final Color YIELD_MARKER_COLOR = new Color(0xFFA528); + + private final PhaseType phaseType; + private PlayerView phaseOwner; private boolean enabled = true; private boolean active = false; private boolean hover = false; + private boolean yieldMarked = false; private Runnable onToggled; - /** - * Shows phase labels, handles repainting and on/off states. A - * PhaseLabel has "skip" and "active" states, meaning - * "this phase is (not) skipped" and "this is the current phase". - * - * @param txt - *   Label text - */ - public PhaseLabel(final String txt) { + public PhaseLabel(final String txt, final PhaseType phaseType) { super(txt); + this.phaseType = phaseType; this.setHorizontalTextPosition(SwingConstants.CENTER); this.setHorizontalAlignment(SwingConstants.CENTER); this.addMouseListener(new MouseAdapter() { @Override public void mousePressed(final MouseEvent e) { + if (SwingUtilities.isRightMouseButton(e)) { + handleRightClick(); + return; + } PhaseLabel.this.enabled = !PhaseLabel.this.enabled; if (PhaseLabel.this.onToggled != null) { PhaseLabel.this.onToggled.run(); @@ -59,47 +73,84 @@ public void mouseExited(final MouseEvent e) { }); } - /** - * Determines whether play pauses at this phase or not. - * - * @param b - *   boolean, true if play pauses - */ + public PhaseType getPhaseType() { + return phaseType; + } + + public PlayerView getPhaseOwner() { + return phaseOwner; + } + + public void setPhaseOwner(final PlayerView v) { + this.phaseOwner = v; + } + + public boolean isYieldMarked() { + return yieldMarked; + } + + public void setYieldMarked(final boolean b) { + if (this.yieldMarked != b) { + this.yieldMarked = b; + repaintOnlyThisLabel(); + } + } + + private void handleRightClick() { + if (phaseOwner == null || phaseType == null) { + return; + } + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + return; + } + CMatchUI ui = CMatchUI.getActive(); + if (ui == null) { + return; + } + IGameController controller = ui.getGameController(); + if (controller == null) { + return; + } + YieldMarker existing = controller.getYieldMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phaseType == existing.getPhase(); + if (clickedSameLabel) { + controller.clearYieldMarker(); + } else { + // Setting a marker implies we want to stop here — un-skip the cell. + // (Skip-phase pref + yield marker would auto-pass past the target.) + this.enabled = true; + repaintOnlyThisLabel(); + controller.setYieldMarker(phaseOwner, phaseType); + // Pass current priority so the marker takes effect immediately. + controller.selectButtonOk(); + } + // Net controller stores state locally without a UI hook; refresh explicitly so chevron updates. + PlayerView local = ui.getCurrentPlayer(); + if (local != null) { + ui.refreshYieldUi(local); + } + } + @Override public void setEnabled(final boolean b) { this.enabled = b; } - /** - * Determines whether play pauses at this phase or not. - * - * @return boolean - */ public boolean getEnabled() { return this.enabled; } - /** Fires after the user toggles this label by clicking. */ public void setOnToggled(final Runnable r) { this.onToggled = r; } - /** - * Makes this phase the current phase (or not). - * - * @param b - *   boolean, true if phase is current - */ public void setActive(final boolean b) { this.active = b; this.repaintOnlyThisLabel(); } - /** - * Determines if this phase is the current phase (or not). - * - * @return boolean - */ public boolean getActive() { return this.active; } @@ -110,37 +161,61 @@ public void repaintOnlyThisLabel() { repaint(0, 0, d.width, d.height); } - /* - * (non-Javadoc) - * - * @see javax.swing.JComponent#paintComponent(java.awt.Graphics) - */ @Override public void paintComponent(final Graphics g) { final int w = this.getWidth(); final int h = this.getHeight(); - FSkin.SkinColor c; - // Set color according to skip or active or hover state of label + // Precedence: hover > yieldMarked > active/enabled combinations. if (this.hover) { - c = FSkin.getColor(FSkin.Colors.CLR_HOVER); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_HOVER)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); + } + else if (this.yieldMarked) { + g.setColor(YIELD_MARKER_COLOR); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (this.active && this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_ENABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_ENABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (!this.active && this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_ENABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_ENABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (this.active && !this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_DISABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_DISABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_DISABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_DISABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); + } + + if (this.yieldMarked) { + drawChevron(g, w, h); + } else { + super.paintComponent(g); } + } - // Center vertically and horizontally. Show border if active. - FSkin.setGraphicsColor(g, c); - g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); - super.paintComponent(g); + private void drawChevron(final Graphics g, final int w, final int h) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.BLACK); + // Two back-to-back triangles — total width is `size`, total height is `size`. + int size = Math.max(6, (int) (h * 0.55)); + int x = (w - size) / 2; + int y = (h - size) / 2; + int[] xs1 = {x, x + size / 2, x}; + int[] ys1 = {y, y + size / 2, y + size}; + g2.fillPolygon(xs1, ys1, 3); + int[] xs2 = {x + size / 2, x + size, x + size / 2}; + int[] ys2 = {y, y + size / 2, y + size}; + g2.fillPolygon(xs2, ys2, 3); + } finally { + g2.dispose(); + } } -} \ No newline at end of file +} diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 68f98d71958..86af5946c63 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -38,8 +38,10 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldMarker; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.match.HostedMatch; +import forge.interfaces.IGameController; import forge.gui.FThreads; import forge.gui.GuiBase; import forge.gui.util.SGuiChoose; @@ -695,6 +697,46 @@ public boolean isUiSetToSkipPhase(final PlayerView playerTurn, final PhaseType p return !view.stopAtPhase(playerTurn, phase); } + @Override + public void refreshYieldUi(final PlayerView player) { + FThreads.invokeInEdtNowOrLater(() -> { + if (view == null) { + return; + } + // Marker only rendered for the local player's view. + PlayerView local = getCurrentPlayer(); + if (local == null || !local.equals(player)) { + return; + } + for (final VPlayerPanel panel : view.getPlayerPanelsList()) { + VPhaseIndicator pi = panel.getPhaseIndicator(); + if (pi == null) { + continue; + } + for (VPhaseIndicator.PhaseLabel l : pi.allLabels()) { + l.setYieldMarked(false); + } + } + IGameController controller = getGameController(); + YieldMarker marker = controller == null ? null : controller.getYieldMarker(); + if (marker == null) { + return; + } + VPlayerPanel markedPanel = view.getPlayerPanel(marker.getPhaseOwner()); + if (markedPanel == null) { + return; + } + VPhaseIndicator markedPi = markedPanel.getPhaseIndicator(); + if (markedPi == null) { + return; + } + VPhaseIndicator.PhaseLabel target = markedPi.getLabel(marker.getPhase()); + if (target != null) { + target.setYieldMarked(true); + } + }); + } + public static HostedMatch hostMatch() { hostedMatch = new HostedMatch(); return hostedMatch; diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index e07016b1f2d..b2ab7b2a945 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -355,20 +355,27 @@ protected void drawOverlay(Graphics g) { } if (gameMenu != null) { - if (gameMenu.getChildCount() > 1) { + int n = gameMenu.getChildCount(); + if (n > 1) { + // VGameMenu builds: Concede(0), Auto-Yields(1), [Yield Options, Auto-Pass when experimental yield is on], + // then Settings, Show WinLose Overlay (only when !isMobileAdventureMode). + // Settings and Show WinLose are therefore the last two entries when present; + // any pref-gated items between them shift indices but never push Settings off the tail. + int idxShowWinLose = n - 1; + int idxSettings = n - 2; if (viewWinLose == null) { gameMenu.getChildAt(0).setEnabled(!game.isMulligan()); gameMenu.getChildAt(1).setEnabled(!game.isMulligan()); if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(2).setEnabled(!game.isMulligan()); - gameMenu.getChildAt(3).setEnabled(false); + gameMenu.getChildAt(idxSettings).setEnabled(!game.isMulligan()); + gameMenu.getChildAt(idxShowWinLose).setEnabled(false); } } else { gameMenu.getChildAt(0).setEnabled(false); gameMenu.getChildAt(1).setEnabled(false); if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(2).setEnabled(false); - gameMenu.getChildAt(3).setEnabled(true); + gameMenu.getChildAt(idxSettings).setEnabled(false); + gameMenu.getChildAt(idxShowWinLose).setEnabled(true); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index d0fb7c4470a..449eb553885 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -2,8 +2,10 @@ import forge.Forge; import forge.assets.FSkinImage; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.menu.FDropDownMenu; import forge.menu.FMenuItem; +import forge.model.FModel; import forge.screens.match.MatchController; import forge.screens.settings.SettingsScreen; import forge.toolbox.FEvent; @@ -14,6 +16,10 @@ public class VGameMenu extends FDropDownMenu { public VGameMenu() { } + private static boolean isExperimentalYieldEnabled() { + return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + @Override protected void buildMenu() { @@ -62,6 +68,27 @@ public void setVisible(boolean b0) { autoYields.show(); } })); + + if (isExperimentalYieldEnabled()) { + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldOptions"), + Forge.hdbuttons ? FSkinImage.HDPREFERENCE : FSkinImage.SETTINGS, + e -> new VYieldOptions().show())); + + boolean autoPassOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + String autoPassLabel = Forge.getLocalizer().getMessage(autoPassOn ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass"); + addItem(new FMenuItem(autoPassLabel, + Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, + e -> { + boolean newVal = !FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + FModel.getPreferences().setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + FModel.getPreferences().save(); + MatchController.instance.getGameController().setYieldInterruptPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + if (newVal) { + MatchController.instance.getGameController().selectButtonOk(); + } + })); + } + if (!Forge.isMobileAdventureMode) { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblSettings"), Forge.hdbuttons ? FSkinImage.HDPREFERENCE : FSkinImage.SETTINGS, e -> { //pause game when spectating AI Match diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java index 711a62f6833..e8a4cf95693 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java @@ -12,6 +12,12 @@ import forge.assets.FSkinColor.Colors; import forge.assets.FSkinFont; import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMarker; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.MatchController; import forge.toolbox.FContainer; import forge.toolbox.FDisplayObject; import forge.util.TextBounds; @@ -22,8 +28,11 @@ public class VPhaseIndicator extends FContainer { public static final float PADDING_X = Utils.scale(1); public static final float PADDING_Y = Utils.scale(2); + private static final Color YIELD_MARKER_COLOR = new Color(0xFFA528FF); + private final Map phaseLabels = new HashMap<>(); private FSkinFont font; + private PlayerView owner; public VPhaseIndicator() { addPhaseLabel("UP", PhaseType.UPKEEP); @@ -48,6 +57,14 @@ public PhaseLabel getLabel(PhaseType phaseType) { return phaseLabels.get(phaseType); } + public Iterable allLabels() { + return phaseLabels.values(); + } + + public void setOwner(PlayerView player) { + this.owner = player; + } + public void resetPhaseButtons() { for (PhaseLabel lbl : phaseLabels.values()) { lbl.setActive(false); @@ -110,6 +127,7 @@ public class PhaseLabel extends FDisplayObject { private final PhaseType phaseType; private boolean stopAtPhase = false; private boolean active = false; + private boolean yieldMarked = false; private Runnable onToggled; public PhaseLabel(String caption0, PhaseType phaseType0) { @@ -135,7 +153,13 @@ public void setStopAtPhase(boolean stopAtPhase0) { stopAtPhase = stopAtPhase0; } - /** Fires after the user toggles this label by tapping. */ + public boolean isYieldMarked() { + return yieldMarked; + } + public void setYieldMarked(boolean v) { + this.yieldMarked = v; + } + public void setOnToggled(Runnable r) { onToggled = r; } @@ -147,6 +171,40 @@ public boolean tap(float x, float y, int count) { return true; } + @Override + public boolean longPress(float x, float y) { + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS)) { + return false; + } + PlayerView phaseOwner = VPhaseIndicator.this.owner; + if (phaseOwner == null) { + return false; + } + IGameController ctrl = MatchController.instance.getGameController(); + if (ctrl == null) { + return false; + } + YieldMarker existing = ctrl.getYieldMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phaseType == existing.getPhase(); + if (clickedSameLabel) { + ctrl.clearYieldMarker(); + } else { + // Setting a marker implies we want to stop here — un-skip the cell so the marker can fire. + stopAtPhase = true; + ctrl.setYieldMarker(phaseOwner, phaseType); + // Pass current priority so the marker takes effect immediately. + ctrl.selectButtonOk(); + } + // Net controller stores state locally without a UI hook; refresh explicitly so chevron updates. + PlayerView local = MatchController.instance.getCurrentPlayer(); + if (local != null) { + MatchController.instance.refreshYieldUi(local); + } + return true; + } + @Override public void draw(final Graphics g) { float x = PADDING_X; @@ -154,21 +212,36 @@ public void draw(final Graphics g) { float h = getHeight(); //determine back color according to skip or active state of label - FSkinColor backColor; - if (active && stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_ENABLED); - } - else if (!active && stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_ENABLED); - } - else if (active && !stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_DISABLED); - } - else { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_DISABLED); + if (yieldMarked) { + g.fillRect(YIELD_MARKER_COLOR, x, 0, w, h); + drawChevron(g, x, w, h); + // Skip the caption when marked — chevron replaces the phase abbreviation. + } else { + FSkinColor backColor; + if (active && stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_ENABLED); + } + else if (!active && stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_ENABLED); + } + else if (active && !stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_DISABLED); + } + else { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_DISABLED); + } + g.fillRect(isHovered() ? backColor.brighter() : backColor, x, 0, w, h); + g.drawText(caption, isHovered() && font.canIncrease() ? font.increase() : font, Color.BLACK, x, 0, w, h, false, Align.center, true); } - g.fillRect(isHovered() ? backColor.brighter() : backColor, x, 0, w, h); - g.drawText(caption, isHovered() && font.canIncrease() ? font.increase() : font, Color.BLACK, x, 0, w, h, false, Align.center, true); + } + + private void drawChevron(final Graphics g, float x, float w, float h) { + // Two back-to-back triangles centered in the cell, mirroring desktop. + float size = Math.max(Utils.scale(6f), h * 0.55f); + float cx = x + (w - size) / 2f; + float cy = (h - size) / 2f; + g.fillTriangle(Color.BLACK, cx, cy, cx + size / 2f, cy + size / 2f, cx, cy + size); + g.fillTriangle(Color.BLACK, cx + size / 2f, cy, cx + size, cy + size / 2f, cx + size / 2f, cy + size); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java index 25735826693..5bf0a89626f 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java @@ -94,6 +94,7 @@ private static FSkinColor getDeliriumHighlight() { public VPlayerPanel(PlayerView player0, boolean showHand, int playerCount) { player = player0; phaseIndicator = add(new VPhaseIndicator()); + phaseIndicator.setOwner(player); if (playerCount > 2) { forMultiPlayer = true; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VStack.java b/forge-gui-mobile/src/forge/screens/match/views/VStack.java index db97402fbf5..378ad962a92 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VStack.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VStack.java @@ -13,6 +13,7 @@ import forge.Graphics; import forge.assets.FSkinColor; import forge.assets.FSkinFont; +import forge.assets.FSkinImage; import forge.assets.TextRenderer; import forge.card.CardRenderer; import forge.card.CardRenderer.CardStackPosition; @@ -25,11 +26,13 @@ import forge.gui.card.CardDetailUtil; import forge.gui.card.CardDetailUtil.DetailColors; import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.menu.FCheckBoxMenuItem; import forge.menu.FDropDown; import forge.menu.FMenuItem; import forge.menu.FMenuTab; import forge.menu.FPopupMenu; +import forge.model.FModel; import forge.player.PlayerZoneUpdates; import forge.screens.match.MatchController; import forge.screens.match.MatchScreen; @@ -283,11 +286,14 @@ public boolean tap(float x, float y, int count) { final GameView gameView = MatchController.instance.getGameView(); final IGameController controller = MatchController.instance.getGameController(); final PlayerView player = MatchController.instance.getCurrentPlayer(); - if (player != null) { //don't show menu if tapping on art - if (stackInstance.isAbility()) { - FPopupMenu menu = new FPopupMenu() { - @Override - protected void buildMenu() { + final boolean experimentalYield = FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + // Spells contribute no yield-related menu items unless the experimental flag adds + // "Yield to entire stack" — fall through to CardZoom otherwise to preserve direct-tap UX. + if (player != null && (stackInstance.isAbility() || experimentalYield)) { + FPopupMenu menu = new FPopupMenu() { + @Override + protected void buildMenu() { + if (stackInstance.isAbility()) { final String key = stackInstance.getKey(); final boolean autoYield = controller.shouldAutoYield(key); addItem(new FCheckBoxMenuItem(Forge.getLocalizer().getMessage("cbpAutoYieldMode"), autoYield, @@ -323,13 +329,21 @@ protected void buildMenu() { } })); } - addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); } - }; + if (experimentalYield) { + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToEntireStack"), + Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, + e -> { + controller.setStackYield(true); + controller.passPriority(); + })); + } + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); + } + }; - menu.show(this, x, y); - return true; - } + menu.show(this, x, y); + return true; } CardZoom.show(stackInstance.getSourceCard()); return true; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java new file mode 100644 index 00000000000..bf54cf47321 --- /dev/null +++ b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java @@ -0,0 +1,268 @@ +package forge.screens.match.views; + +import com.badlogic.gdx.utils.Align; + +import forge.Forge; +import forge.assets.FSkinColor; +import forge.assets.FSkinColor.Colors; +import forge.assets.FSkinFont; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.MatchController; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FDialog; +import forge.toolbox.FLabel; +import forge.toolbox.FOptionPane; +import forge.toolbox.FScrollPane; +import forge.toolbox.FTextField; +import forge.util.TextBounds; +import forge.util.Utils; + +public class VYieldOptions extends FDialog { + + private static final String[] STACK_SCOPE_VALUES = { "never", "always", "stack", "turn" }; + private static final String[] NO_ACTIONS_SCOPE_VALUES = { "never", "always", "turn" }; + + private static final FSkinFont DESC_FONT = FSkinFont.get(10); + private static final FSkinColor DESC_COLOR = FSkinColor.get(Colors.CLR_TEXT).alphaColor(0.55f); + + private final FScrollPane scroller; + + private final FLabel hdrInterrupts; + private final FCheckBox chkInterruptAttackers; + private final FCheckBox chkInterruptTargeting; + private final FCheckBox chkInterruptMassRemoval; + private final FCheckBox chkInterruptOpponentSpell; + private final FCheckBox chkInterruptTriggers; + private final FCheckBox chkInterruptReveal; + private final FCheckBox chkAutoPassRespectsInterrupts; + + private final FLabel hdrSuggestions; + private final FLabel lblStackScope; + private final FComboBox cboStackScope; + private final FLabel lblNoActionsScope; + private final FComboBox cboNoActionsScope; + + private final FCheckBox chkSuppressOwnTurn; + private final FCheckBox chkSuppressAfterYield; + + private final FLabel hdrSpeed; + private final FLabel lblBudget; + private final FLabel descBudget; + private final FTextField txtBudgetMs; + private final FCheckBox chkSkipPhaseDelay; + private final FCheckBox chkSkipResolveDelay; + + public VYieldOptions() { + super(Forge.getLocalizer().getMessage("lblYieldSettings"), 1); + final IGameController ctrl = MatchController.instance.getGameController(); + final ForgePreferences prefs = FModel.getPreferences(); + + scroller = add(new FScrollPane() { + @Override + protected ScrollBounds layoutAndGetScrollBounds(float visibleWidth, float visibleHeight) { + return layoutScrollerContent(visibleWidth); + } + }); + + hdrInterrupts = scroller.add(headerLabel("lblInterruptSettings")); + chkInterruptAttackers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnAttackers"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS))); + chkInterruptTargeting = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTargeting"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_TARGETING))); + chkInterruptMassRemoval = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnMassRemoval"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL))); + chkInterruptOpponentSpell = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnOpponentSpell"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL))); + chkInterruptTriggers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTriggers"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS))); + chkInterruptReveal = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnReveal"), ctrl.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_REVEAL))); + chkAutoPassRespectsInterrupts = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblAutoPassRespectsInterrupts"), ctrl.getYieldInterruptPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS))); + + chkInterruptAttackers.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_ATTACKERS, chkInterruptAttackers.isSelected())); + chkInterruptTargeting.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_TARGETING, chkInterruptTargeting.isSelected())); + chkInterruptMassRemoval.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, chkInterruptMassRemoval.isSelected())); + chkInterruptOpponentSpell.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, chkInterruptOpponentSpell.isSelected())); + chkInterruptTriggers.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_TRIGGERS, chkInterruptTriggers.isSelected())); + chkInterruptReveal.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_INTERRUPT_ON_REVEAL, chkInterruptReveal.isSelected())); + chkAutoPassRespectsInterrupts.setCommand(e -> persistInterrupt(ctrl, FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, chkAutoPassRespectsInterrupts.isSelected())); + + hdrSuggestions = scroller.add(headerLabel("lblAutomaticSuggestions")); + lblStackScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestStackYield")) + .align(Align.left) + .build()); + cboStackScope = scroller.add(new FComboBox()); + cboStackScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeNever")); + cboStackScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeAlways")); + cboStackScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeStack")); + cboStackScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeTurn")); + cboStackScope.setSelectedIndex(indexOf(STACK_SCOPE_VALUES, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD))); + cboStackScope.setDropDownChangeHandler(e -> persistScope(prefs, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, STACK_SCOPE_VALUES, cboStackScope.getSelectedIndex())); + + lblNoActionsScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestNoActions")) + .align(Align.left) + .build()); + cboNoActionsScope = scroller.add(new FComboBox()); + cboNoActionsScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeNever")); + cboNoActionsScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeAlways")); + cboNoActionsScope.addItem(Forge.getLocalizer().getMessage("lblDeclScopeTurn")); + cboNoActionsScope.setSelectedIndex(indexOf(NO_ACTIONS_SCOPE_VALUES, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS))); + cboNoActionsScope.setDropDownChangeHandler(e -> persistScope(prefs, FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, NO_ACTIONS_SCOPE_VALUES, cboNoActionsScope.getSelectedIndex())); + + chkSuppressOwnTurn = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressOnOwnTurn"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_ON_OWN_TURN))); + chkSuppressAfterYield = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressAfterYield"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_AFTER_END))); + chkSuppressOwnTurn.setCommand(e -> persistBool(prefs, FPref.YIELD_SUPPRESS_ON_OWN_TURN, chkSuppressOwnTurn.isSelected())); + chkSuppressAfterYield.setCommand(e -> persistBool(prefs, FPref.YIELD_SUPPRESS_AFTER_END, chkSuppressAfterYield.isSelected())); + + hdrSpeed = scroller.add(headerLabel("lblSpeedSettings")); + lblBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetLabel")) + .align(Align.left) + .build()); + descBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetDesc")) + .font(DESC_FONT) + .textColor(DESC_COLOR) + .align(Align.left) + .build()); + int currentBudget = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + txtBudgetMs = scroller.add(new FTextField(currentBudget > 0 ? String.valueOf(currentBudget) : "")); + txtBudgetMs.setGhostText(Forge.getLocalizer().getMessage("lblDynamic")); + txtBudgetMs.setIsNumeric(true); + + chkSkipPhaseDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipPhaseDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_PHASE_DELAY))); + chkSkipResolveDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipResolveDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_RESOLVE_DELAY))); + chkSkipPhaseDelay.setCommand(e -> persistBool(prefs, FPref.YIELD_SKIP_PHASE_DELAY, chkSkipPhaseDelay.isSelected())); + chkSkipResolveDelay.setCommand(e -> persistBool(prefs, FPref.YIELD_SKIP_RESOLVE_DELAY, chkSkipResolveDelay.isSelected())); + + initButton(0, Forge.getLocalizer().getMessage("lblOK"), e -> { + String txt = txtBudgetMs.getText().trim(); + if (txt.isEmpty()) { + txt = "0"; + } + try { + int v = Integer.parseInt(txt); + if (v < 0) { + v = 0; + } + prefs.setPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, String.valueOf(v)); + prefs.save(); + } catch (NumberFormatException nfe) { + FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblInvalidBudget")); + return; + } + hide(); + }); + } + + private static FLabel headerLabel(String localizerKey) { + // ButtonBuilder gives the gradient bar background used by SettingsScreen group headers. + return new FLabel.ButtonBuilder() + .text(Forge.getLocalizer().getMessage(localizerKey)) + .font(FSkinFont.get(14)) + .align(Align.center) + .build(); + } + + private FScrollPane.ScrollBounds layoutScrollerContent(float visibleWidth) { + float padding = FOptionPane.PADDING; + float rowGap = Utils.scale(6); + float sectionGap = Utils.scale(14); + + float x = padding; + float w = visibleWidth - 2 * padding; + float y = padding; + + TextBounds rowBounds = chkInterruptAttackers.getAutoSizeBounds(); + float rowH = Math.max(rowBounds.height, Utils.scale(28)); + // Match SettingsScreen.GROUP_HEADER_HEIGHT ratio so the gradient bar reads as a section. + float headerH = Math.round(Utils.AVG_FINGER_HEIGHT * 0.6f); + + hdrInterrupts.setBounds(x, y, w, headerH); + y += headerH; + FCheckBox[] interrupts = { + chkInterruptAttackers, chkInterruptTargeting, chkInterruptMassRemoval, + chkInterruptOpponentSpell, chkInterruptTriggers, chkInterruptReveal, + chkAutoPassRespectsInterrupts + }; + for (FCheckBox cb : interrupts) { + cb.setBounds(x, y, w, rowH); + y += rowH + rowGap; + } + y += sectionGap - rowGap; + + hdrSuggestions.setBounds(x, y, w, headerH); + y += headerH; + + float dropdownW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float scopeLabelW = w - dropdownW - padding; + float scopeRowH = Math.max(rowH, FTextField.getDefaultHeight()); + + lblStackScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboStackScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + lblNoActionsScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboNoActionsScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + chkSuppressOwnTurn.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSuppressAfterYield.setBounds(x, y, w, rowH); + y += rowH + sectionGap; + + hdrSpeed.setBounds(x, y, w, headerH); + y += headerH; + + float fieldH = FTextField.getDefaultHeight(); + float fieldW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float lblW = w - fieldW - padding; + lblBudget.setBounds(x, y, lblW, fieldH); + txtBudgetMs.setBounds(x + w - fieldW, y, fieldW, fieldH); + y += fieldH + Utils.scale(2); + descBudget.setBounds(x, y, w, DESC_FONT.getCapHeight() * 2.2f); + y += DESC_FONT.getCapHeight() * 2.2f + rowGap; + + chkSkipPhaseDelay.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSkipResolveDelay.setBounds(x, y, w, rowH); + y += rowH + padding; + + return new FScrollPane.ScrollBounds(visibleWidth, y); + } + + private static int indexOf(String[] options, String value) { + for (int i = 0; i < options.length; i++) { + if (options[i].equals(value)) { + return i; + } + } + return 0; + } + + private static void persistInterrupt(IGameController ctrl, FPref pref, boolean value) { + FModel.getPreferences().setPref(pref, value); + FModel.getPreferences().save(); + ctrl.setYieldInterruptPref(pref, value); + } + + private static void persistBool(ForgePreferences prefs, FPref pref, boolean value) { + prefs.setPref(pref, value); + prefs.save(); + } + + private static void persistScope(ForgePreferences prefs, FPref pref, String[] values, int index) { + if (index < 0 || index >= values.length) { + return; + } + prefs.setPref(pref, values[index]); + prefs.save(); + } + + @Override + protected float layoutAndGetHeight(float width, float maxHeight) { + scroller.setBounds(0, 0, width, maxHeight); + scroller.scrollToTop(); + return maxHeight; + } +} diff --git a/forge-gui/res/defaults/match.xml b/forge-gui/res/defaults/match.xml index 839733ea2a8..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 d7660d94a7e..691ec9220e9 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1590,7 +1590,68 @@ 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.\nPress Cancel to take an action. +lblYieldingUntilNextPhase=Yielding until next phase.\nPress Cancel to take an action. +cbYieldExperimentalOptions=Enable Advanced Yield Options +nlYieldExperimentalOptions=Adds advanced yield options: yield until stack clears, yield until your next turn, smart yield suggestions, and interrupt configuration. Options in Game toolbar. +lblYieldingUntilStackClears=Yielding until stack clears.\nPress Cancel to take an action. +lblYieldingUntilYourNextTurn=Yielding until your next turn.\nPress Cancel to take an action. +lblYieldingUntilBeforeCombat=Yielding until combat.\nPress Cancel to take an action. +lblYieldingUntilEndStep=Yielding until end step.\nPress Cancel to take an action. +lblYieldingUntilBeforeYourTurn=Yielding until end step before your turn.\nPress Cancel to take an action. +lblAutoPassingNoActions=Auto-passing — no actions available. +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.) +lblYieldSuggestionDeclineHintStack=(Declining disables this prompt until the stack clears.) +lblYieldSuggestion=Yield Suggestion +lblAccept=Accept +lblDecline=Decline +lblYieldOptions=Yield Options +lblInterruptSettings=Yield Interrupt Settings +lblAutomaticSuggestions=Automatic Yield Suggestions +lblInterruptOnAttackers=When attackers declared against you +lblInterruptOnTargeting=When targeted by spell or ability +lblInterruptOnOpponentSpell=When opponent casts a spell or activates an ability +lblInterruptOnReveal=When cards revealed or choices made +lblInterruptOnMassRemoval=When mass removal spell cast +lblInterruptOnTriggers=When triggered abilities on stack +lblAutoPassRespectsInterrupts=Auto-pass respects interrupts +lblSuggestStackYield=When can''t respond to stack +lblSuggestNoMana=When no mana available +lblSuggestNoActions=When no actions available +lblSuppressOnOwnTurn=Suppress on own turn +lblSuppressAfterYield=Suppress immediately after yield ends +lblDeclScopeNever=Never +lblDeclScopeAlways=Always +lblDeclScopeStack=Once per stack +lblDeclScopeTurn=Once per turn +lblYieldSettings=Yield Settings +lblInterruptSettingsTooltip=Configure interrupt conditions and automatic suggestions + +lblYieldBtnClearStack=Clear Stack +lblYieldBtnClearStackTooltip=Pass priority until the stack is empty. +lblYield=Yield +lblAutoPassNoActions=Auto-Pass when no actions available +lblAutoPassBudgetLabel=Auto-pass calculation timeout (ms) +lblAutoPassBudgetDesc=Dynamic = 50ms × playable cards (50-1500ms) +lblDynamic=Dynamic +lblInvalidBudget=Budget must be a non-negative integer. +lblSpeedSettings=Speed Settings +lblSkipPhaseDelay=Skip delay between phases +lblSkipResolveDelay=Skip delay when stack resolves +lblSHORTCUT_YIELD_AUTO_PASS=Yield: Toggle Auto-Pass +lblSHORTCUT_YIELD_CANCEL=Yield: Cancel +lblEnableAdvancedYieldOptions=Enable Advanced Yield Options +lblSHORTCUT_YIELD_OPTIONS=Yield: Toggle Yield Options +lblYieldBtnAutoPass=Auto-Pass: OFF +lblYieldBtnAutoPassOn=Auto-Pass: ON +lblYieldBtnAutoPassTooltip=Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare. Respects interrupt settings. +lblYieldingUntilPhaseFmt=Yielding until {0} +lblYieldHostDisabled={0} has enabled advanced yield options. Host must also enable for this setting to function correctly. +lblYieldHostEnabled=Host has enabled advanced yield options. +lblYieldHostToggleDisabled=Host has disabled advanced yield options. lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: @@ -2490,6 +2551,7 @@ lblControlsVote=You choose how each player votes. #VStack.java lblAlwaysYes=Always Yes lblAlwaysNo=Always No +lblYieldToEntireStack=Yield to entire stack lblZoomOrDetails=Zoom/Details #AdvancedSearch.java lblRulesText=Rules Text 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 c916ed99514..73ad50e690c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -114,6 +114,9 @@ public final GameView getGameView() { @Override public void setGameView(final GameView gameView0) { if (gameView == null || gameView0 == null) { + if (gameView0 == null && yieldController != null) { + yieldController.reset(); + } if (gameView0 != null) { gameView0.updateObjLookup(); } @@ -169,7 +172,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 @@ -418,7 +421,15 @@ public String getConcedeCaption() { // 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(this); + } + return yieldController; + } /** * Automatically pass priority until reaching the Cleanup phase of the @@ -426,26 +437,28 @@ public String getConcedeCaption() { */ @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); + } + + @Override + public final boolean isAutoPassingNoActions(final PlayerView player) { + return getYieldController().isAutoPassingNoActions(player); + } + + @Override + public final boolean shouldAutoYieldForPlayer(final PlayerView player) { + return getYieldController().shouldAutoYieldForPlayer(player); } private Timer awaitNextInputTimer; @@ -581,13 +594,65 @@ 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()); + } + + @Override + public void activateYieldMarker(PlayerView player, YieldMarker marker) { + getYieldController().setYieldMarker(player, marker); + } + + @Override + public void clearYieldMarker(PlayerView player) { + getYieldController().clearYieldMarker(player); + } + + @Override + public void setStackYieldUiState(PlayerView player, boolean active) { + getYieldController().setStackYield(player, active); + } + + @Override + public void applyRemoteYieldMarker(PlayerView player, YieldMarker marker) { + player = PlayerView.findById(getGameView(), player); + if (player == null) return; + getYieldController().setYieldMarkerSilent(player, marker); + } + + @Override + public void applyRemoteStackYield(PlayerView player, boolean active) { + player = PlayerView.findById(getGameView(), player); + if (player == null) return; + getYieldController().setStackYieldSilent(player, active); + } + + @Override + public void syncYieldMarkerCleared(PlayerView player) { + player = PlayerView.findById(getGameView(), player); + if (player == null) return; + getYieldController().clearYieldMarkerSilent(player); + // Silent path skipped the UI hook; trigger it here. + refreshYieldUi(player); } + + @Override + public YieldMarker getCurrentYieldMarker(PlayerView player) { + return getYieldController().getYieldMarker(player); + } + + @Override + public boolean isCurrentStackYieldActive(PlayerView player) { + return getYieldController().isStackYieldActive(player); + } + + @Override + public void refreshYieldUi(PlayerView player) { + } + + @Override + public void setHostYieldEnabled(boolean enabled) { + } + // End auto-yield/input code /** diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java new file mode 100644 index 00000000000..591c07301e3 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -0,0 +1,542 @@ +/* + * 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.gui.interfaces.IGuiGame; +import forge.localinstance.properties.ForgePreferences; +import forge.model.FModel; +import forge.trackable.TrackableTypes; +import forge.util.Localizer; + +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. + * + * Per-player state is a phase-targeted YieldMarker plus a boolean stack-yield flag; + * either, both, or neither may be active. + */ +public class YieldController { + + private final IGuiGame gui; + + // Legacy turn-boundary auto-pass set; written by the End-Turn cancel button (any pref state). + private final Set autoPassUntilEndOfTurn = Sets.newConcurrentHashSet(); + + // Immutable so map readers see a consistent snapshot via Map#compute. + private static final class YieldState { + final YieldMarker marker; // null = no marker active + final boolean stackYield; // true = yield until stack empties + final boolean hasLeftMarker; // true once priority has been observed somewhere other than the marker location since activation + final boolean activationOnMarker; // true if priority was at marker location at the moment of activation + + private YieldState(YieldMarker marker, boolean stackYield, boolean hasLeftMarker, boolean activationOnMarker) { + this.marker = marker; + this.stackYield = stackYield; + this.hasLeftMarker = hasLeftMarker; + this.activationOnMarker = activationOnMarker; + } + + static YieldState empty() { + return new YieldState(null, false, false, false); + } + + YieldState withMarker(YieldMarker m, boolean hasLeft, boolean activationOnMarker) { + return new YieldState(m, this.stackYield, hasLeft, activationOnMarker); + } + + YieldState withStackYield(boolean active) { + return new YieldState(this.marker, active, this.hasLeftMarker, this.activationOnMarker); + } + + YieldState withHasLeftMarker(boolean hasLeft) { + return new YieldState(this.marker, this.stackYield, hasLeft, this.activationOnMarker); + } + + boolean isEmpty() { + return marker == null && !stackYield; + } + } + + private final Map yieldStates = Maps.newConcurrentMap(); + + public YieldController(IGuiGame gui) { + this.gui = gui; + } + + public void autoPassUntilEndOfTurn(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + autoPassUntilEndOfTurn.add(player); + } + + public void autoPassCancel(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + if (!autoPassUntilEndOfTurn.remove(player)) { + return; + } + + // Prevent prompt getting stuck on yielding message while actually waiting for next input opportunity + gui.showPromptMessage(player, ""); + gui.updateButtons(player, false, false, false); + gui.awaitNextInput(); + } + + public boolean mayAutoPass(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + // Yield states self-clear when their stop condition fires. + // Must run before isAutoPassingNoActions or that short-circuits and the state never clears. + if (shouldAutoYieldForPlayer(player)) { + return true; + } + return isAutoPassingNoActions(player); + } + + /** Persistent preference toggle (YIELD_AUTO_PASS_NO_ACTIONS), not a one-shot yield mode. */ + public boolean isAutoPassingNoActions(PlayerView player) { + if (!isYieldExperimentalEnabled()) { + return false; + } + boolean prefValue = getInterruptPref(ForgePreferences.FPref.YIELD_AUTO_PASS_NO_ACTIONS); + if (!prefValue) { + return false; + } + // Interrupts only break through when the player has opted in. Without an action to take, + // stopping on interrupts becomes a "press OK to continue" ceremony with no decision. + if (getInterruptPref(ForgePreferences.FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS) + && shouldInterruptYield(player)) { + return false; + } + // Respect phase-skip settings: pass through unmarked phases even if + // the player has actions. Auto-pass should be additive to phase-skip, + // not cause stops at phases the user explicitly set to skip. + GameView gv = gui.getGameView(); + if (gv != null && gv.getStack() != null && gv.getStack().isEmpty()) { + PlayerView turnPlayer = gv.getPlayerTurn(); + forge.game.phase.PhaseType phase = gv.getPhase(); + if (turnPlayer != null && phase != null + && gui.isUiSetToSkipPhase(turnPlayer, phase)) { + return true; + } + } + return !player.hasAvailableActions(); + } + + private boolean getInterruptPref(ForgePreferences.FPref pref) { + forge.interfaces.IGameController controller = gui.getGameController(); + if (controller == null) { + return "true".equals(pref.getDefault()); + } + return controller.getYieldInterruptPref(pref); + } + + public void updateAutoPassPrompt(PlayerView player) { + player = TrackableTypes.PlayerViewType.lookup(player); + + if (autoPassUntilEndOfTurn.contains(player)) { + gui.cancelAwaitNextInput(); + gui.showPromptMessage(player, Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn")); + gui.updateButtons(player, false, true, false); + return; + } + + YieldState state = yieldStates.get(player); + if (state != null && !state.isEmpty()) { + gui.cancelAwaitNextInput(); + Localizer loc = Localizer.getInstance(); + final String message; + if (state.stackYield) { + message = loc.getMessage("lblYieldingUntilStackClears"); + } else if (state.marker != null) { + message = loc.getMessage("lblYieldingUntilPhaseFmt", state.marker.getPhase().nameForUi); + } else { + message = loc.getMessage("lblYieldingUntilEndOfTurn"); + } + gui.showPromptMessage(player, message); + gui.updateButtons(player, false, true, false); + return; + } + + // Persistent auto-pass: clear stale prompt from previous input (e.g. Pay Mana Cost). + if (isAutoPassingNoActions(player)) { + gui.cancelAwaitNextInput(); + gui.showPromptMessage(player, Localizer.getInstance().getMessage("lblAutoPassingNoActions")); + gui.updateButtons(player, false, false, false); + } + } + + public void setYieldMarker(PlayerView player, YieldMarker marker) { + setYieldMarkerInternal(player, marker, true); + } + + public void setYieldMarkerSilent(PlayerView player, YieldMarker marker) { + setYieldMarkerInternal(player, marker, false); + } + + public void clearYieldMarker(PlayerView player) { + setYieldMarkerInternal(player, null, true); + } + + public void clearYieldMarkerSilent(PlayerView player) { + setYieldMarkerInternal(player, null, false); + } + + private void setYieldMarkerInternal(PlayerView player, YieldMarker marker, boolean notifyGui) { + final PlayerView key = TrackableTypes.PlayerViewType.lookup(player); + // Setting a marker takes priority over the legacy auto-pass set. + autoPassUntilEndOfTurn.remove(key); + // If activating while priority is already at the marker location, we must + // first leave that phase before the marker can fire (otherwise it would + // trigger immediately on the same activation moment). Otherwise treat + // the marker as already "left" so the next reach to its location fires. + final boolean atMarkerNow = marker != null && isPriorityAt(marker); + yieldStates.compute(key, (p, prev) -> { + YieldState base = (prev == null) ? YieldState.empty() : prev; + YieldState next = base.withMarker( + marker, + marker == null ? false : !atMarkerNow, + marker != null && atMarkerNow); + return next.isEmpty() ? null : next; + }); + if (notifyGui) { + gui.refreshYieldUi(key); + } + } + + private boolean isPriorityAt(YieldMarker marker) { + GameView gv = gui.getGameView(); + if (gv == null) { + return false; + } + PlayerView turnPlayer = gv.getPlayerTurn(); + forge.game.phase.PhaseType phase = gv.getPhase(); + return turnPlayer != null + && turnPlayer.equals(marker.getPhaseOwner()) + && phase == marker.getPhase(); + } + + public void setStackYield(PlayerView player, boolean active) { + setStackYieldInternal(player, active, true); + } + + public void setStackYieldSilent(PlayerView player, boolean active) { + setStackYieldInternal(player, active, false); + } + + private void setStackYieldInternal(PlayerView player, boolean active, boolean notifyGui) { + final PlayerView key = TrackableTypes.PlayerViewType.lookup(player); + if (active) { + autoPassUntilEndOfTurn.remove(key); + } + yieldStates.compute(key, (p, prev) -> { + YieldState base = (prev == null) ? YieldState.empty() : prev; + YieldState next = base.withStackYield(active); + return next.isEmpty() ? null : next; + }); + if (notifyGui) { + gui.refreshYieldUi(key); + } + } + + public YieldMarker getYieldMarker(PlayerView player) { + YieldState s = yieldStates.get(TrackableTypes.PlayerViewType.lookup(player)); + return s == null ? null : s.marker; + } + + public boolean isStackYieldActive(PlayerView player) { + YieldState s = yieldStates.get(TrackableTypes.PlayerViewType.lookup(player)); + return s != null && s.stackYield; + } + + public boolean shouldAutoYieldForPlayer(PlayerView player) { + final PlayerView key = TrackableTypes.PlayerViewType.lookup(player); + if (autoPassUntilEndOfTurn.contains(key)) { + return true; + } + + if (!isYieldExperimentalEnabled()) { + return false; + } + + YieldState state = yieldStates.get(key); + if (state == null || state.isEmpty()) { + return false; + } + + if (shouldInterruptYield(key)) { + // Interrupt cancels both marker and stack-yield; mirror to the client. + boolean hadMarker = state.marker != null; + yieldStates.remove(key); + gui.refreshYieldUi(key); + if (hadMarker) { + gui.syncYieldMarkerCleared(key); + } + promptCleared(key); + return false; + } + + GameView gameView = gui.getGameView(); + if (gameView == null) { + return false; + } + + boolean stillYielding = false; + + if (state.stackYield) { + boolean stackEmpty = gameView.getStack() == null || gameView.getStack().isEmpty(); + if (stackEmpty) { + yieldStates.compute(key, (p, prev) -> { + if (prev == null) return null; + YieldState next = prev.withStackYield(false); + return next.isEmpty() ? null : next; + }); + gui.refreshYieldUi(key); + state = yieldStates.get(key); + } else { + stillYielding = true; + } + } + + // Marker fires the first time priority reaches (phaseOwner, phase) AFTER activation, + // OR — if a phase past the marker is observed in the same turn (game-rule skip, + // e.g. DECLARE_BLOCKERS without attackers) — fires there too. + // If activated while already at the marker location, must first leave AND return, + // not just leave: a same-phase right-click means "next cycle", not "next phase". + YieldMarker marker = state == null ? null : state.marker; + if (marker != null) { + PlayerView turnPlayer = gameView.getPlayerTurn(); + forge.game.phase.PhaseType currentPhase = gameView.getPhase(); + + boolean inMarkerOwnerTurn = turnPlayer != null + && turnPlayer.equals(marker.getPhaseOwner()); + boolean atTarget = inMarkerOwnerTurn + && currentPhase == marker.getPhase(); + boolean pastTarget = inMarkerOwnerTurn + && currentPhase != null + && marker.getPhase() != null + && currentPhase.isAfter(marker.getPhase()); + + // Activation-on-marker case waits for full cycle (atTarget only). + // Activation-off-marker case fires on at-or-past target (handles game-rule skips). + boolean shouldFire = state.hasLeftMarker + && (atTarget || (!state.activationOnMarker && pastTarget)); + + if (shouldFire) { + yieldStates.compute(key, (p, prev) -> { + if (prev == null) return null; + YieldState next = prev.withMarker(null, false, false); + return next.isEmpty() ? null : next; + }); + gui.refreshYieldUi(key); + gui.syncYieldMarkerCleared(key); + if (!stillYielding) { + promptCleared(key); + } + } else { + if (!atTarget && !state.hasLeftMarker) { + // First observation away from the marker location — record it. + yieldStates.compute(key, (p, prev) -> { + if (prev == null) return null; + YieldState next = prev.withHasLeftMarker(true); + return next.isEmpty() ? null : next; + }); + } + stillYielding = true; + } + } + + return stillYielding; + } + + private void promptCleared(PlayerView player) { + gui.showPromptMessage(player, ""); + gui.updateButtons(player, false, false, false); + gui.awaitNextInput(); + } + + private boolean shouldInterruptYield(final PlayerView player) { + GameView gameView = gui.getGameView(); + if (gameView == null) { + return false; + } + + forge.game.phase.PhaseType phase = gameView.getPhase(); + forge.game.combat.CombatView combatView = gameView.getCombat(); + + if (getInterruptPref(ForgePreferences.FPref.YIELD_INTERRUPT_ON_ATTACKERS)) { + if (phase == forge.game.phase.PhaseType.COMBAT_DECLARE_ATTACKERS && + combatView != null && isBeingAttacked(combatView, player)) { + return true; + } + } + + if (getInterruptPref(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 (getInterruptPref(ForgePreferences.FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + forge.game.spellability.StackItemView topItem = gameView.peekStack(); + if (topItem != null) { + PlayerView activatingPlayer = topItem.getActivatingPlayer(); + boolean isOpponent = activatingPlayer != null && !activatingPlayer.equals(player); + if (isOpponent && targetsPlayerOrPermanents(topItem, player)) { + return true; + } + } + } + + if (getInterruptPref(ForgePreferences.FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL)) { + if (hasMassRemovalOnStack(gameView, player)) { + return true; + } + } + + if (getInterruptPref(ForgePreferences.FPref.YIELD_INTERRUPT_ON_TRIGGERS)) { + forge.util.collect.FCollectionView stack = gameView.getStack(); + if (stack != null) { + for (forge.game.spellability.StackItemView si : stack) { + if (si.isTrigger()) { + return true; + } + } + } + } + + return false; + } + + private boolean isBeingAttacked(forge.game.combat.CombatView combatView, PlayerView player) { + if (combatView == null) { + return false; + } + + forge.util.collect.FCollection attackersOfPlayer = combatView.getAttackersOf(player); + if (attackersOfPlayer != null && !attackersOfPlayer.isEmpty()) { + return true; + } + + // Check planeswalkers / battles controlled by the player. + 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; + } + + /** Recurses into sub-instances (e.g. Oona, where targeting is in a sub-ability). */ + 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; + } + + /** Host-only: walks live engine stack via gameView.getGame(). Opponent spells only. */ + private boolean hasMassRemovalOnStack(GameView gameView, PlayerView player) { + forge.game.Game game = gameView.getGame(); + if (game == null) { + return false; // host-only path; defensive + } + for (forge.game.spellability.SpellAbilityStackInstance si : game.getStack()) { + forge.game.player.Player activator = si.getActivatingPlayer(); + if (activator == null || activator.getView().equals(player)) { + continue; + } + if (isMassRemovalInstance(si)) { + return true; + } + } + return false; + } + + /** Recurses into sub-instances for modal spells like Farewell. */ + private boolean isMassRemovalInstance(forge.game.spellability.SpellAbilityStackInstance si) { + forge.game.spellability.SpellAbility sa = si.getSpellAbility(); + if (sa != null && isMassRemovalApi(sa.getApi())) { + return true; + } + forge.game.spellability.SpellAbilityStackInstance subInstance = si.getSubInstance(); + if (subInstance != null && isMassRemovalInstance(subInstance)) { + return true; + } + return false; + } + + private boolean isMassRemovalApi(forge.game.ability.ApiType api) { + return api == forge.game.ability.ApiType.DestroyAll + || api == forge.game.ability.ApiType.DamageAll + || api == forge.game.ability.ApiType.SacrificeAll + || api == forge.game.ability.ApiType.ChangeZoneAll; + } + + private boolean isYieldExperimentalEnabled() { + return FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + public void removeFromLegacyAutoPass(PlayerView player) { + autoPassUntilEndOfTurn.remove(player); + } + + /** Clears all yield state. Called between games so state doesn't carry over. */ + public void reset() { + autoPassUntilEndOfTurn.clear(); + yieldStates.clear(); + } + +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java new file mode 100644 index 00000000000..4e0821d6443 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java @@ -0,0 +1,35 @@ +package forge.gamemodes.match; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; + +import java.io.Serializable; +import java.util.Objects; + +/** Immutable (phaseOwner, phase) target of a yield-until-phase intent. */ +public final class YieldMarker implements Serializable { + private static final long serialVersionUID = 1L; + + private final PlayerView phaseOwner; + private final PhaseType phase; + + public YieldMarker(PlayerView phaseOwner, PhaseType phase) { + this.phaseOwner = Objects.requireNonNull(phaseOwner); + this.phase = Objects.requireNonNull(phase); + } + + public PlayerView getPhaseOwner() { return phaseOwner; } + public PhaseType getPhase() { return phase; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof YieldMarker)) return false; + YieldMarker other = (YieldMarker) o; + return phaseOwner.equals(other.phaseOwner) && phase == other.phase; + } + @Override + public int hashCode() { return Objects.hash(phaseOwner, phase); } + @Override + public String toString() { return phaseOwner + "@" + phase; } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldPrefs.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldPrefs.java new file mode 100644 index 00000000000..f2d4bc4275e --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldPrefs.java @@ -0,0 +1,114 @@ +/* + * 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 forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; + +import java.io.Serializable; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** Immutable snapshot of a player's yield-related interrupt preferences, used for bulk network sync. */ +public final class YieldPrefs implements Serializable { + private static final long serialVersionUID = 1L; + + /** Boolean interrupt FPrefs captured in a bulk snapshot. */ + static final FPref[] INTERRUPT_PREFS = { + FPref.YIELD_INTERRUPT_ON_ATTACKERS, + FPref.YIELD_INTERRUPT_ON_TARGETING, + FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, + FPref.YIELD_INTERRUPT_ON_TRIGGERS, + FPref.YIELD_INTERRUPT_ON_REVEAL, + FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, + FPref.YIELD_AUTO_PASS_NO_ACTIONS, + }; + + private final boolean onAttackers; + private final boolean onTargeting; + private final boolean onOpponentSpell; + private final boolean onTriggers; + private final boolean onReveal; + private final boolean onMassRemoval; + private final boolean autoPassNoActions; + + private YieldPrefs(boolean onAttackers, boolean onTargeting, + boolean onOpponentSpell, boolean onTriggers, + boolean onReveal, boolean onMassRemoval, boolean autoPassNoActions) { + this.onAttackers = onAttackers; + this.onTargeting = onTargeting; + this.onOpponentSpell = onOpponentSpell; + this.onTriggers = onTriggers; + this.onReveal = onReveal; + this.onMassRemoval = onMassRemoval; + this.autoPassNoActions = autoPassNoActions; + } + + /** Snapshot from an IGameController (controller-layer state). */ + public YieldPrefs(IGameController controller) { + this( + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS), + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_TARGETING), + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL), + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS), + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_REVEAL), + controller.getYieldInterruptPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL), + controller.getYieldInterruptPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) + ); + } + + /** Snapshot from local ForgePreferences (used at game-start before a controller exists). */ + public static YieldPrefs fromCurrentPreferences() { + ForgePreferences prefs = FModel.getPreferences(); + return new YieldPrefs( + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_ATTACKERS), + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_TARGETING), + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL), + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_TRIGGERS), + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_REVEAL), + prefs.getPrefBoolean(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL), + prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS) + ); + } + + /** Returns false if {@code pref} is not a recognized yield interrupt key. */ + public boolean getInterrupt(FPref pref) { + return switch (pref) { + case YIELD_INTERRUPT_ON_ATTACKERS -> onAttackers; + case YIELD_INTERRUPT_ON_TARGETING -> onTargeting; + case YIELD_INTERRUPT_ON_OPPONENT_SPELL -> onOpponentSpell; + case YIELD_INTERRUPT_ON_TRIGGERS -> onTriggers; + case YIELD_INTERRUPT_ON_REVEAL -> onReveal; + case YIELD_INTERRUPT_ON_MASS_REMOVAL -> onMassRemoval; + case YIELD_AUTO_PASS_NO_ACTIONS -> autoPassNoActions; + default -> false; + }; + } + + /** Returns a read-only view of the boolean interrupt prefs keyed by FPref. */ + public Map getInterrupts() { + EnumMap map = new EnumMap<>(FPref.class); + for (FPref pref : INTERRUPT_PREFS) { + map.put(pref, getInterrupt(pref)); + } + return Collections.unmodifiableMap(map); + } +} 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 f4cd9f833bb..d3c65c34fa4 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,12 +18,18 @@ package forge.gamemodes.match.input; import forge.game.Game; +import forge.game.GameView; import forge.game.card.Card; import forge.game.player.Player; +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; import forge.game.player.actions.PassPriorityAction; import forge.game.spellability.SpellAbility; +import forge.game.spellability.StackItemView; import forge.gamemodes.net.server.FServerManager; import forge.gamemodes.net.server.FServerManager.AfkTimeout; +import forge.gui.GuiBase; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.GamePlayerUtil; @@ -31,6 +37,7 @@ import forge.util.ITriggerEvent; import forge.util.Localizer; import forge.util.ThreadUtil; +import forge.util.collect.FCollectionView; import java.util.ArrayList; import java.util.List; @@ -49,6 +56,22 @@ public class InputPassPriority extends InputSyncronizedBase { private List chosenSa; + private enum PendingSuggestion { + NONE(null, null), + STACK_YIELD("STACK_YIELD", FPref.YIELD_DECLINE_SCOPE_STACK_YIELD), + NO_ACTIONS("NO_ACTIONS", FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + + final String declineKey; + final FPref scopePref; + PendingSuggestion(String declineKey, FPref scopePref) { + this.declineKey = declineKey; + this.scopePref = scopePref; + } + } + + private PendingSuggestion pendingSuggestion = PendingSuggestion.NONE; + private String pendingSuggestionMessage = null; + public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } @@ -69,6 +92,95 @@ public void showAndWait() { /** {@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().didYieldJustEnd(); + + if (isExperimentalYieldEnabled() && !isAlreadyYielding() && !suppressDueToYieldEnd) { + ForgePreferences prefs = FModel.getPreferences(); + + // Skip suggestions when persistent auto-pass is active — the user + // already opted into automatic passing, one-shot yield suggestions + // are redundant and confusing (especially after interrupt recovery). + boolean autoPassActive = getController().getYieldInterruptPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + if (autoPassActive) { + showNormalPrompt(); + return; + } + + // Early exit: if both suggestion types are disabled (scope = "never"), + // skip the entire smart-suggestion block including stack-transition tracking. + // No state to maintain because no decline tracking happens for "never" scopes. + boolean stackYieldOff = "never".equals(prefs.getPref(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD)); + boolean noActionsOff = "never".equals(prefs.getPref(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS)); + if (stackYieldOff && noActionsOff) { + showNormalPrompt(); + return; + } + + Localizer loc = Localizer.getInstance(); + + // Track stack transitions for per-stack decline scope + GameView gvForStack = getGameView(); + boolean stackNonEmpty = gvForStack != null && gvForStack.getStack() != null + && !gvForStack.getStack().isEmpty(); + getController().onPriorityReceived(stackNonEmpty); + + // Suggestion 1: Stack items but can't respond + // Check decline state first — short-circuits the expensive + // hasAvailableActions read when the suggestion is declined. + if (!getController().isSuggestionDeclined(PendingSuggestion.STACK_YIELD.declineKey) + && shouldShowStackYieldPrompt()) { + pendingSuggestion = PendingSuggestion.STACK_YIELD; + pendingSuggestionMessage = loc.getMessage("lblCannotRespondToStackYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + // Suggestion 2: No available actions (empty hand, no abilities) + if (!getController().isSuggestionDeclined(PendingSuggestion.NO_ACTIONS.declineKey) + && shouldShowNoActionsPrompt()) { + pendingSuggestion = PendingSuggestion.NO_ACTIONS; + pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + } + + showNormalPrompt(); + } + + private void showYieldSuggestionPrompt() { + // State may have flipped between the initial check and now (e.g. async multiplayer click). + if (isAlreadyYielding()) { + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + + Localizer loc = Localizer.getInstance(); + String fullMessage = pendingSuggestionMessage; + String scope = FModel.getPreferences().getPref(pendingSuggestion.scopePref); + if ("stack".equals(scope)) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHintStack"); + } else if ("turn".equals(scope)) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHint"); + } + showMessage(fullMessage); + chosenSa = null; + getController().getGui().updateButtons(getOwner(), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline"), + true, true, true); + getController().getGui().alertUser(); + } + + private void showNormalPrompt() { + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); @@ -82,9 +194,54 @@ public final void showMessage() { getController().getGui().alertUser(); } + private boolean isAlreadyYielding() { + return getController().getYieldMarker() != null + || getController().isStackYieldActive(); + } + /** {@inheritDoc} */ @Override protected final void onOk() { + if (pendingSuggestion != PendingSuggestion.NONE) { + if (isAlreadyYielding()) { + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + stop(); + return; + } + // CYield.toggleAutoPass enables the pref then calls selectButtonOk to advance the input. + // If we land here with a pending suggestion AND the pref is on, the user just toggled — + // the suggestion couldn't have appeared with the pref already on (mayAutoPass would have + // caught it). Suppress the accidental accept. Skip for remote proxies: the host's local + // pref doesn't apply to remote players, so this guard would false-positive on every Accept. + if (!getController().getGui().isRemoteGuiProxy() + && FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + stop(); + return; + } + + PendingSuggestion accepted = pendingSuggestion; + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + if (accepted == PendingSuggestion.STACK_YIELD) { + getController().setStackYield(true); + } else if (accepted == PendingSuggestion.NO_ACTIONS) { + // UPKEEP because UNTAP has no priority pass — a marker on UNTAP could never fire. + PlayerView self = getPlayerView(); + if (self != null) { + getController().setYieldMarker(self, PhaseType.UPKEEP); + } + } + if (isAlreadyYielding()) { + stop(); + } else { + showNormalPrompt(); + } + return; + } + passPriority(() -> { getController().macros().addRememberedAction(new PassPriorityAction()); stop(); @@ -94,8 +251,20 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { - if (!getController().tryUndoLastAction()) { //undo if possible - //otherwise end turn + // If declining a yield suggestion, track the decline and show normal prompt + if (pendingSuggestion != PendingSuggestion.NONE) { + if (pendingSuggestion.declineKey != null) { + getController().declineSuggestion(pendingSuggestion.declineKey); + } + pendingSuggestion = PendingSuggestion.NONE; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + + if (!getController().tryUndoLastAction()) { + // Phase markers can't express "yield until the current turn ends regardless of player", + // so the End-Turn cancel button uses the legacy turn-boundary auto-pass. passPriority(() -> { getController().autoPassUntilEndOfTurn(); stop(); @@ -191,4 +360,84 @@ public boolean selectAbility(final SpellAbility ab) { } return false; } + + // Smart yield suggestion helper methods + + private boolean isExperimentalYieldEnabled() { + // Smart yield suggestions are desktop-only because the mobile yield panel + // doesn't exist. This check disables suggestions for the host process when + // it happens to be running on libgdx (mobile-as-host scenario), even if a + // connected desktop client could otherwise use them. + 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 PlayerView.findById(getController().getGui().getGameView(), getOwner()); + } + + private boolean checkHasAvailableActions() { + Player player = getController().getPlayer(); + if (player == null) return false; + // Read-only: the value is freshened at the top of + // PlayerControllerHuman.chooseSpellAbilityToPlay before mayAutoPass() + // consumes it. Recomputing here just doubled the work each priority pass. + return player.getView().hasAvailableActions(); + } + + private boolean shouldShowStackYieldPrompt() { + GameView gv = getGameView(); + if (gv == null) return false; + + FCollectionView stack = gv.getStack(); + if (stack == null || stack.isEmpty()) { + return false; + } + + return !checkHasAvailableActions(); + } + + /** + * 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 shouldShowNoActionsPrompt() { + GameView gv = getGameView(); + PlayerView pv = getPlayerView(); + if (gv == null || pv == null) return false; + + if (!isValidSuggestionContext(gv, pv)) { + return false; + } + + return !checkHasAvailableActions(); + } } 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 2aac7bd9f63..cfba684e965 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -10,9 +10,11 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldPrefs; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.skin.FSkinProp; import forge.player.PlayerZoneUpdates; import forge.trackable.TrackableCollection; @@ -71,6 +73,9 @@ public enum ProtocolMethod implements IHasForgeLog { restoreOldZones (Mode.SERVER, Void.TYPE, PlayerView.class, PlayerZoneUpdates.class), setRememberedActions(Mode.SERVER, Void.TYPE), nextRememberedAction(Mode.SERVER, Void.TYPE), + // Server -> client: marker auto-cleared (priority reached the marked phase) + syncYieldMarkerCleared(Mode.SERVER, Void.TYPE, PlayerView.class), + setHostYieldEnabled (Mode.SERVER, Void.TYPE, Boolean.TYPE), showWaitingTimer (Mode.SERVER, Void.TYPE, PlayerView.class, String.class), setHighlighted (Mode.SERVER, Void.TYPE, GameEntityView.class, Boolean.TYPE), applyDelta (Mode.SERVER, Void.TYPE, DeltaPacket.class), @@ -98,7 +103,12 @@ public enum ProtocolMethod implements IHasForgeLog { setShouldAlwaysAcceptTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), setShouldAlwaysDeclineTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), setShouldAlwaysAskTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), - setUiShouldSkipPhase (Mode.CLIENT, Void.TYPE, PlayerView.class, PhaseType.class, Boolean.TYPE); + setYieldMarker (Mode.CLIENT, Void.TYPE, PlayerView.class, PhaseType.class), + clearYieldMarker (Mode.CLIENT, Void.TYPE), + setStackYield (Mode.CLIENT, Void.TYPE, Boolean.TYPE), + setYieldInterruptPref (Mode.CLIENT, Void.TYPE, ForgePreferences.FPref.class, Boolean.TYPE), + setYieldPrefs (Mode.CLIENT, Void.TYPE, YieldPrefs.class), + setUiShouldSkipPhase (Mode.CLIENT, Void.TYPE, PlayerView.class, PhaseType.class, Boolean.TYPE); private enum Mode { SERVER(IGuiGame.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java b/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java index 0f20a48669b..cbb30c8ef51 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java @@ -153,7 +153,7 @@ public void addLobbyListener(final ILobbyListener listener) { void setGameControllers(final Iterable myPlayers) { for (final PlayerView p : myPlayers) { - NetGameController controller = new NetGameController(this); + NetGameController controller = new NetGameController(this, clientGui, p); clientGui.setOriginalGameController(p, controller); controller.replayActiveYields(); } 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 cabab229155..30acf80a64e 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 @@ -6,8 +6,11 @@ import forge.game.player.actions.PlayerAction; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldPrefs; import forge.gamemodes.net.GameProtocolSender; import forge.gamemodes.net.ProtocolMethod; +import forge.gui.interfaces.IGuiGame; import forge.interfaces.IDevModeCheats; import forge.interfaces.IGameController; import forge.interfaces.IMacroSystem; @@ -19,15 +22,23 @@ import forge.util.ITriggerEvent; import java.util.List; +import java.util.Map; public class NetGameController implements IGameController { private final GameProtocolSender sender; + private final IGuiGame clientGui; + private final PlayerView playerView; private final AutoYieldStore yieldStore = new AutoYieldStore(); - public NetGameController(final IToServer server) { + private final java.util.EnumMap yieldInterruptPrefs = + new java.util.EnumMap<>(ForgePreferences.FPref.class); + + public NetGameController(final IToServer server, final IGuiGame clientGui, final PlayerView playerView) { sender = new GameProtocolSender(server); + this.clientGui = clientGui; + this.playerView = playerView; } private void send(final ProtocolMethod method, final Object... args) { @@ -265,4 +276,64 @@ public String playbackText() { return null; } } + + // Delegate to the local YieldController so reads see auto-cleared state from server-driven syncs. + @Override + public YieldMarker getYieldMarker() { + return clientGui.getCurrentYieldMarker(playerView); + } + + @Override + public void setYieldMarker(final PlayerView phaseOwner, final PhaseType phase) { + if (phaseOwner == null || phase == null) { + clearYieldMarker(); + return; + } + clientGui.activateYieldMarker(playerView, new YieldMarker(phaseOwner, phase)); + send(ProtocolMethod.setYieldMarker, phaseOwner, phase); + } + + @Override + public void clearYieldMarker() { + clientGui.clearYieldMarker(playerView); + send(ProtocolMethod.clearYieldMarker); + } + + @Override + public boolean isStackYieldActive() { + return clientGui.isCurrentStackYieldActive(playerView); + } + + @Override + public void setStackYield(final boolean active) { + clientGui.setStackYieldUiState(playerView, active); + send(ProtocolMethod.setStackYield, active); + } + + @Override + public boolean getYieldInterruptPref(final ForgePreferences.FPref pref) { + Boolean stored = yieldInterruptPrefs.get(pref); + return stored != null ? stored : "true".equals(pref.getDefault()); + } + + @Override + public void setYieldInterruptPref(final ForgePreferences.FPref pref, final boolean value) { + yieldInterruptPrefs.put(pref, value); + send(ProtocolMethod.setYieldInterruptPref, pref, value); + } + + @Override + public YieldPrefs getYieldPrefs() { + return new YieldPrefs(this); + } + + @Override + public void setYieldPrefs(final YieldPrefs prefs) { + if (prefs == null) return; + this.yieldInterruptPrefs.clear(); + for (Map.Entry e : prefs.getInterrupts().entrySet()) { + this.yieldInterruptPrefs.put(e.getKey(), e.getValue()); + } + send(ProtocolMethod.setYieldPrefs, prefs); + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index a08ecfe9f67..26821cfadfc 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -392,6 +392,22 @@ public void setLobbyListener(final ILobbyListener listener) { this.lobbyListener = listener; } + /** + * Tell all remote clients whether the host has advanced yield options enabled. + */ + public void broadcastHostYieldEnabled(boolean enabled) { + final HostedMatch hostedMatch = localLobby.getHostedMatch(); + if (hostedMatch == null) { return; } + final Game game = hostedMatch.getGame(); + if (game == null) { return; } + for (final Player p : game.getPlayers()) { + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + if (gui instanceof RemoteClientGuiGame) { + gui.setHostYieldEnabled(enabled); + } + } + } + public void updateLobbyState() { final LobbyUpdateEvent event = new LobbyUpdateEvent(localLobby.getData()); broadcast(event); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index f9193b422e2..dbe29911338 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -63,6 +63,11 @@ public RemoteClient getClient() { return client; } + @Override + public boolean isRemoteGuiProxy() { + return true; + } + public void pause() { paused = true; } @@ -479,6 +484,16 @@ public boolean isUiSetToSkipPhase(final PlayerView playerTurn, final PhaseType p return false; } + @Override + public void syncYieldMarkerCleared(final PlayerView player) { + send(ProtocolMethod.syncYieldMarkerCleared, player); + } + + @Override + public void setHostYieldEnabled(final boolean enabled) { + send(ProtocolMethod.setHostYieldEnabled, enabled); + } + @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 24a1f89f3e1..7f9ace8da91 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.YieldMarker; import forge.game.player.DelayedReveal; import forge.game.player.IHasIcon; import forge.game.player.PlayerView; @@ -47,6 +48,8 @@ default void setGameView(GameView gameView, long sequenceNumber) { void setOriginalGameController(PlayerView view, IGameController gameController); void setGameController(PlayerView player, IGameController gameController); + IGameController getGameController(); + void setSpectator(IGameController spectator); void openView(TrackableCollection myPlayers); @@ -263,9 +266,34 @@ default List many(final String title, final String topCaption, final int void autoPassUntilEndOfTurn(PlayerView player); boolean mayAutoPass(PlayerView player); + + boolean isAutoPassingNoActions(PlayerView player); + + boolean shouldAutoYieldForPlayer(PlayerView player); + + /** Returns true if this GUI is a server-side proxy for a remote player. */ + default boolean isRemoteGuiProxy() { return false; } + void autoPassCancel(PlayerView player); void updateAutoPassPrompt(); + void activateYieldMarker(PlayerView player, YieldMarker marker); + void clearYieldMarker(PlayerView player); + void setStackYieldUiState(PlayerView player, boolean active); + + /** Apply remote-client intent without re-broadcasting. */ + void applyRemoteYieldMarker(PlayerView player, YieldMarker marker); + void applyRemoteStackYield(PlayerView player, boolean active); + + void syncYieldMarkerCleared(PlayerView player); + + YieldMarker getCurrentYieldMarker(PlayerView player); + boolean isCurrentStackYieldActive(PlayerView player); + + void refreshYieldUi(PlayerView player); + + void setHostYieldEnabled(boolean enabled); + void setCurrentPlayer(PlayerView player); /** diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index b08517d37d9..e170ad3a044 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -7,6 +7,9 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldPrefs; +import forge.localinstance.properties.ForgePreferences; import forge.util.ITriggerEvent; public interface IGameController { @@ -47,10 +50,7 @@ public interface IGameController { void reorderHand(CardView card, int index); - /** - * Request a full state resync from the server. - * Called automatically when checksum validation fails to recover from desynchronization. - */ + /** Request a full state resync from the server. */ void requestResync(); // --- Auto-yield preferences (per-player) --- @@ -73,5 +73,19 @@ public interface IGameController { void setShouldAlwaysDeclineTrigger(int trigger); void setShouldAlwaysAskTrigger(int trigger); + // --- Yield marker (phase-targeted) and stack-yield state (per-player) --- + default YieldMarker getYieldMarker() { return null; } + default void setYieldMarker(PlayerView phaseOwner, PhaseType phase) { } + default void clearYieldMarker() { } + + default boolean isStackYieldActive() { return false; } + default void setStackYield(boolean active) { } + + // --- Interrupt preferences (per-player) --- + boolean getYieldInterruptPref(ForgePreferences.FPref pref); + void setYieldInterruptPref(ForgePreferences.FPref pref, boolean value); + YieldPrefs getYieldPrefs(); + void setYieldPrefs(YieldPrefs prefs); + void setUiShouldSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean shouldSkip); } 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 163433a3386..c7bcf22ecd9 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -132,6 +132,24 @@ public enum FPref implements PreferencesStore.IPref { UI_MULTIPLAYER_FIELD_PANELS ("SPLIT"), UI_CLOSE_ACTION ("NONE"), UI_MANA_LOST_PROMPT ("false"), + + // Experimental yield options (feature-gated) + YIELD_EXPERIMENTAL_OPTIONS("false"), + YIELD_INTERRUPT_ON_ATTACKERS("true"), + YIELD_INTERRUPT_ON_TARGETING("true"), + YIELD_INTERRUPT_ON_OPPONENT_SPELL("false"), + YIELD_INTERRUPT_ON_TRIGGERS("false"), + YIELD_INTERRUPT_ON_REVEAL("false"), + YIELD_INTERRUPT_ON_MASS_REMOVAL("true"), + YIELD_SUPPRESS_ON_OWN_TURN("true"), + YIELD_SUPPRESS_AFTER_END("true"), + YIELD_DECLINE_SCOPE_STACK_YIELD("stack"), + YIELD_DECLINE_SCOPE_NO_ACTIONS("turn"), + YIELD_AUTO_PASS_NO_ACTIONS("false"), + YIELD_AUTO_PASS_RESPECTS_INTERRUPTS("false"), + YIELD_AVAILABLE_ACTIONS_BUDGET_MS("0"), + YIELD_SKIP_PHASE_DELAY("false"), + YIELD_SKIP_RESOLVE_DELAY("false"), UI_STACK_EFFECT_NOTIFICATION_POLICY ("Never"), UI_LAND_PLAYED_NOTIFICATION_POLICY ("Never"), UI_PAUSE_WHILE_MINIMIZED("false"), @@ -308,6 +326,9 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_SHOWHOTKEYS("72"), SHORTCUT_PANELTABS("17 84"), SHORTCUT_CARDOVERLAYS("17 79"), + SHORTCUT_YIELD_OPTIONS("17 89"), // Ctrl+Y + SHORTCUT_YIELD_AUTO_PASS("113"), // F2 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 28ecd42587d..9a34cfd7078 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3,6 +3,7 @@ import com.google.common.collect.*; import forge.LobbyPlayer; import forge.StaticData; +import forge.ai.AvailableActions; import forge.ai.GameState; import forge.ai.PlayerControllerAi; import forge.card.*; @@ -21,6 +22,7 @@ import forge.game.card.token.TokenInfo; import forge.game.combat.Combat; import forge.game.combat.CombatUtil; +import forge.game.phase.PhaseType; import forge.game.cost.*; import forge.game.event.GameEventAddLog; import forge.game.event.GameEventPlayerStatsChanged; @@ -29,7 +31,6 @@ import forge.game.mana.Mana; import forge.game.mana.ManaConversionMatrix; import forge.game.mana.ManaCostBeingPaid; -import forge.game.phase.PhaseType; import forge.game.player.*; import forge.game.player.actions.SelectCardAction; import forge.game.player.actions.SelectPlayerAction; @@ -48,7 +49,11 @@ import forge.game.zone.Zone; import forge.game.zone.ZoneType; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldPrefs; import forge.gamemodes.match.input.*; +import forge.gamemodes.net.event.MessageEvent; +import forge.gamemodes.net.server.FServerManager; import forge.util.IHasForgeLog; import forge.gui.FThreads; import forge.gui.GuiBase; @@ -110,6 +115,11 @@ public class PlayerControllerHuman extends PlayerController implements IGameCont // Empty/missing entry returns false (don't skip), matching a fresh UI's conservative default private final Map> remoteSkipPhases = Maps.newHashMap(); + // Yield state: authoritative for remote proxies; local path reads YieldController via getGui(). + private YieldMarker yieldMarker; + private boolean stackYieldActive; + private final EnumMap yieldInterruptPrefs = new EnumMap<>(FPref.class); + protected final InputQueue inputQueue; protected final InputProxy inputProxy; @@ -929,6 +939,24 @@ 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. + // Gate on the host's experimental flag so legacy UNTIL_END_OF_TURN users + // are unaffected. Read the interrupt pref from the active player's source + // (host's local prefs vs the remote client's stored snapshot). + if (isYieldExperimentalEnabled()) { + if (isYieldActive() + && !getActivePlayerInterruptPref(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) { @@ -1474,15 +1502,19 @@ public CardCollectionView tuckCardsViaMulligan(CardCollectionView hand, int card @Override public void declareAttackers(final Player attackingPlayer, final Combat combat) { if (mayAutoPass()) { - if (CombatUtil.validateAttackers(combat)) { - return; // don't prompt to declare attackers if user chose to - // end the turn and not attacking is legal + if (isAutoPassingNoActions()) { + // Don't cancel — APINA resumes on subsequent priority passes + if (!CombatUtil.canAttack(attackingPlayer)) { + return; + } + } else { + // Yield mode — skip if empty combat is legal; must-attack effects (Goad, etc.) still need a prompt + if (CombatUtil.validateAttackers(combat)) { + return; + } } - // otherwise: cancel auto pass because of this unexpected attack - autoPassCancel(); } - // This input should not modify combat object itself, but should return user choice final InputAttack inpAttack = new InputAttack(this, attackingPlayer, combat); inpAttack.showAndWait(); } @@ -1501,7 +1533,17 @@ public List chooseSpellAbilityToPlay() { player.getName(), getGame().getPhaseHandler().getPhase(), getGame().isGameOver()); final MagicStack stack = getGame().getStack(); + if (FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS) + && !getGui().shouldAutoYieldForPlayer(getLocalPlayerView())) { + long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); + boolean result = AvailableActions.compute(getPlayer(), timeoutMs); + getPlayer().getView().setHasAvailableActions(result); + } + if (mayAutoPass()) { + // Update prompt so it doesn't stay stuck on the previous message + // (e.g. a trigger prompt that was already resolved) + getGui().updateAutoPassPrompt(); // avoid prompting for input if current phase is set to be // auto-passed instead posing a short delay if needed to // prevent the game jumping ahead too quick @@ -1509,10 +1551,11 @@ public List chooseSpellAbilityToPlay() { if (stack.isEmpty()) { // make sure to briefly pause at phases you're not set up to skip if (!isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), - getGame().getPhaseHandler().getPhase())) { + getGame().getPhaseHandler().getPhase()) + && !FModel.getPreferences().getPrefBoolean(FPref.YIELD_SKIP_PHASE_DELAY)) { delay = FControlGamePlayback.phasesDelay; } - } else { + } else if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_SKIP_RESOLVE_DELAY)) { // pause slightly longer for spells and abilities on the stack resolving delay = FControlGamePlayback.resolveDelay; } @@ -1523,8 +1566,12 @@ public List chooseSpellAbilityToPlay() { e.printStackTrace(); } } - netLog.trace("Returning null (mayAutoPass) for player {}", player.getName()); - return null; + // Re-check after the delay — yield may have been cancelled during the sleep, + // in which case fall through to show the normal input prompt + if (mayAutoPass()) { + netLog.trace("Returning null (mayAutoPass) for player {}", player.getName()); + return null; + } } if (stack.isEmpty()) { @@ -1538,10 +1585,12 @@ public List chooseSpellAbilityToPlay() { final SpellAbility ability = stack.peekAbility(); if (ability != null && ability.isAbility() && shouldAutoYield(ability.yieldKey())) { // avoid prompt for input if top ability of stack is set to auto-yield - try { - Thread.sleep(FControlGamePlayback.resolveDelay); - } catch (final InterruptedException e) { - e.printStackTrace(); + if (!FModel.getPreferences().getPrefBoolean(FPref.YIELD_SKIP_RESOLVE_DELAY)) { + try { + Thread.sleep(FControlGamePlayback.resolveDelay); + } catch (final InterruptedException e) { + e.printStackTrace(); + } } netLog.trace("Returning null (autoYield) for player {}", player.getName()); return null; @@ -1712,6 +1761,18 @@ public void notifyOfValue(final SpellAbility sa, final GameObject realtedTarget, if (sa != null && sa.isManaAbility()) { getGame().fireEvent(new GameEventAddLog(GameLogEntryType.LAND, message)); } else { + // Skip notification dialog during active yield if "Interrupt on Reveal/Choices" is disabled. + // Gate on the host's experimental flag and read the interrupt pref from the + // active player's source (host's local prefs vs the remote client's stored snapshot). + if (isYieldExperimentalEnabled()) { + if (isYieldActive() + && !getActivePlayerInterruptPref(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(); @@ -3344,8 +3405,77 @@ public void concede() { } } + // Yield-just-ended detection via mayAutoPass transition (true→false) + private boolean wasAutoPassingLastPriority; + private boolean yieldJustEndedFlag; + + // Suggestion decline tracking (reset each turn or on stack transition) + private final Map declinedSuggestionTurn = Maps.newHashMap(); + private boolean lastSeenStackNonEmpty; + public boolean mayAutoPass() { - return getGui().mayAutoPass(getLocalPlayerView()); + boolean result = getGui().mayAutoPass(getLocalPlayerView()); + // Detect yield ending: was auto-passing last priority, not any more + yieldJustEndedFlag = wasAutoPassingLastPriority && !result; + wasAutoPassingLastPriority = result; + return result; + } + + public boolean isAutoPassingNoActions() { + return getGui().isAutoPassingNoActions(getLocalPlayerView()); + } + + private long computeAvailableActionsBudgetMs(Player p) { + int prefMs = FModel.getPreferences().getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + if (prefMs > 0) { + return prefMs; // explicit user override — bypasses clamps + } + int cardCount = p.getCardsIn(ZoneType.Hand).size() + + p.getCardsIn(ZoneType.Battlefield).size() + + p.getCardsIn(ZoneType.Flashback).size(); + return Math.min(1500L, Math.max(50L, 50L * cardCount)); + } + + public boolean didYieldJustEnd() { + boolean flag = yieldJustEndedFlag; + yieldJustEndedFlag = false; + return flag; + } + + public void onPriorityReceived(boolean stackNonEmpty) { + // On stack non-empty → empty transition, clear STACK_YIELD decline if scope is "stack" + if (lastSeenStackNonEmpty && !stackNonEmpty) { + String scope = FModel.getPreferences().getPref(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD); + if ("stack".equals(scope)) { + declinedSuggestionTurn.remove("STACK_YIELD"); + } + } + lastSeenStackNonEmpty = stackNonEmpty; + } + + public void declineSuggestion(String suggestionType) { + GameView gv = getGui().getGameView(); + if (gv == null) return; + declinedSuggestionTurn.put(suggestionType, gv.getTurn()); + } + + public boolean isSuggestionDeclined(String suggestionType) { + // Look up the per-type scope pref + FPref scopePref = "STACK_YIELD".equals(suggestionType) + ? FPref.YIELD_DECLINE_SCOPE_STACK_YIELD + : FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS; + String scope = FModel.getPreferences().getPref(scopePref); + if ("never".equals(scope)) { + return true; // Suggestion disabled entirely + } + if ("always".equals(scope)) { + return false; // "Always" means never suppress + } + // "stack" and "turn" both use turn-number tracking (stack also clears on transition) + GameView gv = getGui().getGameView(); + if (gv == null) return false; + Integer turnDeclined = declinedSuggestionTurn.get(suggestionType); + return turnDeclined != null && turnDeclined == gv.getTurn(); } public void autoPassUntilEndOfTurn() { @@ -3387,6 +3517,127 @@ public void reorderHand(final CardView card, final int index) { player.updateZoneForView(hand); } + @Override + public YieldMarker getYieldMarker() { + if (getGui().isRemoteGuiProxy()) { + return yieldMarker; + } + return getGui().getCurrentYieldMarker(getLocalPlayerView()); + } + + @Override + public void setYieldMarker(final PlayerView phaseOwner, final PhaseType phase) { + if (phaseOwner == null || phase == null) { + clearYieldMarker(); + return; + } + if (!checkHostYieldEnabled()) { + return; + } + YieldMarker marker = new YieldMarker(phaseOwner, phase); + if (getGui().isRemoteGuiProxy()) { + this.yieldMarker = marker; + getGui().applyRemoteYieldMarker(getLocalPlayerView(), marker); + } else { + getGui().activateYieldMarker(getLocalPlayerView(), marker); + getGui().updateAutoPassPrompt(); + } + } + + @Override + public void clearYieldMarker() { + if (getGui().isRemoteGuiProxy()) { + this.yieldMarker = null; + getGui().applyRemoteYieldMarker(getLocalPlayerView(), null); + } else { + getGui().clearYieldMarker(getLocalPlayerView()); + } + } + + @Override + public boolean isStackYieldActive() { + if (getGui().isRemoteGuiProxy()) { + return stackYieldActive; + } + return getGui().isCurrentStackYieldActive(getLocalPlayerView()); + } + + @Override + public void setStackYield(final boolean active) { + if (active && !checkHostYieldEnabled()) { + return; + } + if (getGui().isRemoteGuiProxy()) { + this.stackYieldActive = active; + getGui().applyRemoteStackYield(getLocalPlayerView(), active); + } else { + getGui().setStackYieldUiState(getLocalPlayerView(), active); + getGui().updateAutoPassPrompt(); + } + } + + /** True if the host's experimental yield pref is enabled, or this is a host-side controller (no remote gating needed). */ + private boolean checkHostYieldEnabled() { + if (!getGui().isRemoteGuiProxy()) { + return true; + } + if (isYieldExperimentalEnabled()) { + return true; + } + final FServerManager server = FServerManager.getInstance(); + if (server != null && server.isHosting()) { + server.broadcast(new MessageEvent( + localizer.getMessage("lblYieldHostDisabled", getLocalPlayerView().getName()))); + } + getGui().setHostYieldEnabled(false); + return false; + } + + private boolean isYieldActive() { + return yieldMarker != null || stackYieldActive; + } + + @Override + public boolean getYieldInterruptPref(final FPref pref) { + Boolean stored = yieldInterruptPrefs.get(pref); + if (stored != null) { + return stored; + } + // Unset: host falls through to FModel (user's saved VYieldSettings), + // remote proxies fall back to the FPref default until setYieldPrefs seeds them. + if (getGui().isRemoteGuiProxy()) { + return "true".equals(pref.getDefault()); + } + return FModel.getPreferences().getPrefBoolean(pref); + } + + @Override + public void setYieldInterruptPref(final FPref pref, final boolean value) { + yieldInterruptPrefs.put(pref, value); + } + + @Override + public YieldPrefs getYieldPrefs() { + return new YieldPrefs(this); + } + + @Override + public void setYieldPrefs(final YieldPrefs prefs) { + if (prefs == null) return; + yieldInterruptPrefs.clear(); + for (Map.Entry e : prefs.getInterrupts().entrySet()) { + yieldInterruptPrefs.put(e.getKey(), e.getValue()); + } + } + + private boolean isYieldExperimentalEnabled() { + return FModel.getPreferences().getPrefBoolean(FPref.YIELD_EXPERIMENTAL_OPTIONS); + } + + private boolean getActivePlayerInterruptPref(FPref pref) { + return getYieldInterruptPref(pref); + } + @Override public String chooseCardName(SpellAbility sa, List faces, String message) { ICardFace face = chooseSingleCardFace(sa, faces, message);