diff --git a/docs/User-Guide.md b/docs/User-Guide.md index b947304b252..d3bc5cd1c4a 100644 --- a/docs/User-Guide.md +++ b/docs/User-Guide.md @@ -16,7 +16,8 @@ - [Easier creature type selection](#easier-creature-type-selection) - [Auto-Target](#auto-target) - [Auto-Pay](#auto-pay) - - [Auto-Yield](#auto-yield) + - [Auto-Pass and Yield Options](#auto-pass-and-yield-options) + - [Auto-Yield and Trigger Decisions](#auto-yield-and-trigger-decisions) - [Shift Key helper](#shift-key-helper) - [Full Control](#full-control) - [Repeatable Sequences (Macros)](#repeatable-sequences-macros) @@ -189,16 +190,32 @@ When paying mana costs, you can press Enter/Spacebar or click the Auto button in - You can still manually pay the cost by clicking mana sources in play (e.g. lands) or clicking symbols in your mana pool, which might be a good idea if you want to save specific mana sources for a later play that turn. - you'll still be prompted when paying Sunburst or cards that care what colors are spent to cast it (ex. Firespout). -## Auto-Yield -- When a spell or an ability appears on the stack and it says "(OPTIONAL)" you can right-click it to decide if you want to always accept or to decline it. +## Auto-Pass and Yield Options +**Yielding** lets you hand priority to Forge so it passes on your behalf instead of you needing to click through every priority pass. This helps you get to your desired phase of action quickly. - It is possible to specify the granularity level for auto-yields: the difference is that, for example, when choosing per ability if you auto-yield to Hellrider's triggered ability once, all triggers from other Hellrider cards will be automatically yielded to as well. When choosing per card, you will need to auto-yield to each Hellrider separately. +Forge offers several yield options depending on how long you want to skip prompts: - Note that in when auto-yielding per ability, yields will NOT be automatically cleared between games in a match, which should speed the game up. When auto-yielding per card, yields WILL be automatically cleared between games because they are dependent on card IDs which change from game to game, thus you will need to auto-yield to each card again in each game of the match. +- **Auto-Pass** — a persistent toggle that automatically yields priority when you have no playable actions. Available on **Desktop** via the Auto-Pass dock icon and the **P** hotkey, or on **Mobile** from the in-match Game menu. +- **End Turn** — auto-pass through the rest of the current turn, bypassing any phase stops. Triggered by the End Turn dock button. +- **Yield markers** — auto-pass until a specific phase is reached. Right-click (or long-press) a phase indicator to set one; a fast-forward symbol marks the active cell. Each (player, phase) cell is independent, so in multiplayer you can yield to a specific opponent's end step. +- **Yield to stack / Resolve entire stack** — auto-pass while the stack resolves. Right-click a stack item to choose: **Yield to stack** auto-passes until the stack empties or an interrupt fires (for example, an opponent casts another spell); **Resolve entire stack** keeps auto-passing until the whole stack is empty even if opponents cast more spells. -- Pressing "End Turn" skips your attack phase and doesn't get cancelled automatically if a spell or ability is put on the stack. You'll still be given a chance to declare blockers if your opponent attacks, but after that the rest of your opponent's turn will then progress without you receiving priority. +> [!NOTE] +> For more information and configuration options — including interrupt conditions, automatic yield suggestions, and speed settings — see [Advanced Yield Options](advanced-yield-options.md). + +## Individual Yields and Trigger Decisions +When a spell or an ability appears on the stack you can right-click it to decide if you want to always accept (Always Yes) or always decline it (Always No). For abilities marked "(OPTIONAL)" the same right-click lets you set an auto-yield so you don't get prompted on subsequent activations. + +The granularity and lifetime of these decisions are controlled by the **Auto Yield/Trigger Mode** setting under Settings → Preferences: + +- **Per Card (Each Game)** — decisions are tied to a specific card instance and cleared at the end of each game. You'll need to set them again in the next game of the match. (For example, auto-yielding one Hellrider does not affect another copy of Hellrider.) +- **Per Ability (Each Match)** — decisions apply to every copy of the ability and persist across games within the current match, then clear when you start a new match. +- **Per Ability (Each Session)** — decisions persist across matches until you close Forge. +- **Per Ability (Each Install)** — decisions are saved to disk and persist across Forge restarts. + +Pick a longer-lived scope when you want recurring triggers (e.g. routine ETBs, upkeep optional triggers) to stay yielded across many games; pick a shorter scope when you want a clean slate each game. - To alleviate pressing this accidentally, as long as you're passing this way, you'll be able to press Escape or the Cancel button to be given the chance to act again. Phases with stops and spells/abilities resolving will be given a slight delay to allow you to see what's going on. +The current list of active auto-yields and Always Yes / Always No trigger decisions is visible from Game → Auto-Yields and Triggers, where individual entries can be cleared. ## Shift Key helper * When you mouse over a flip, transform or Morph (controlled by you) card in battlefield, hold SHIFT to see other state of that card at the side panel that displays card picture and details. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 105088fd83a..100927cf60c 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -6,6 +6,7 @@ - [AI](ai.md) - [Network Play](network-play.md) - [Advanced search](Advanced-Search.md) + - [Advanced Yield Options](advanced-yield-options.md) - Adventure Mode diff --git a/docs/advanced-yield-options.md b/docs/advanced-yield-options.md new file mode 100644 index 00000000000..ef93e9eaa89 --- /dev/null +++ b/docs/advanced-yield-options.md @@ -0,0 +1,125 @@ +## Contents +- [How to Access](#how-to-access) +- [Auto-Pass](#auto-pass) +- [Yield markers](#yield-markers) +- [Yield Settings Menu](#yield-settings-menu) + - [Yield Interrupt Settings](#yield-interrupt-settings) + - [Automatic Yield Suggestions](#automatic-yield-suggestions) + - [Speed Options](#speed-options) +- [Troubleshooting](#troubleshooting) +- [Network Play](#network-play) + +# Advanced Yield Options +The standard priority system in Forge can involve dozens of priority passes every turn. This can can slow down the game, particularly in multiplayer games like Commander, where one player's delay responding to priority halts the game for everybody else. + +Forge offers a range of **Advanced Yield Options** to: + +- enable players to automatically yield when there is no available action they can take. +- give players the ability to yield until a specific phase is reached, without responding to priority passes in the meantime. +- configure yield interrupt conditions, so you'll always get control back when something important happens (e.g. you are attacked or targeted by a spell). +- provide smart suggestions to enable yield if there are no useful actions you can take (e.g. it is another player's turn and you have no mana or playable cards). + +These features are highly configurable through the **Yield Settings** dialog, and can be set up to suit your own gameplay preferences. + +## Auto-Pass + +**Auto-Pass** is a persistent toggle (**P** on desktop, or the Auto-Pass icon on the dock) that automatically passes priority whenever you have no playable actions available. It's the simplest way to speed up games where you often have nothing to do — enable it once and Forge stops asking for input you'd only use to pass. The same **P** key also doubles as a "stop everything" shortcut: if any yield is currently active (transient yield or Auto-Pass itself), pressing P clears them all in one shot without dismissing any prompt that's up. + +**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. +- On desktop, the Auto-Pass dock icon's background lights up gold while active. On mobile, the menu entry text reads `Auto-Pass: ON` / `Auto-Pass: OFF`. + +**Interaction with interrupts:** By default, Auto-Pass ignores your interrupt settings — it keeps passing as long as you have no actions, regardless of attackers, opponent spells, mass-removal, etc. Enable **Auto-pass respects interrupts** in the Yield Interrupt Settings section if you want interrupts to break Auto-Pass too. + +**Persistence:** Unlike yield markers, Auto-Pass does not end on a game event. It stays active until you toggle it off. + +**Performance and timeout:** The action-availability scan can be expensive in complex board states. The scan is subject to the **Auto-pass calculation timeout** setting in the Yield Settings dialog. On timeout the system prompts you instead of auto-passing, so a false positive means an extra prompt rather than a long stall. The default is **Dynamic** — the budget scales with the number of playable cards (approximately 50ms per card, clamped between 50ms and 1500ms). Set your own value in the Yield Settings dialog to override. + +> [!NOTE] +> **The Auto-pass AI is not perfect.** It is designed to avoid false negatives (passing priority when there is action you can take) as much as possible. There may be times it produces a false positive (giving you priority when there is nothing you can do). Use with appropriate caution. + +## Yield markers + +A **yield marker** tells Forge to auto-pass priority until a specific phase is reached. Markers are set directly on the phase indicator strip in the match UI. + +**Setting a marker:** +- **Desktop:** right-click the phase indicator cell for the phase you want to yield to. +- **Mobile:** long-press the phase indicator cell. + +A fast-forward symbol will appear on the targeted cell to show the marker is active. The prompt area also describes what phase you are yielding to. Forge then auto-passes priority on your behalf until that phase is reached, at which point the marker clears automatically and you regain priority. + +**Per-(player, phase) precision:** Each phase indicator cell is distinct per player. Right-clicking your own End Step yields to *your* End Step; right-clicking an opponent's End Step yields to *that opponent's* End Step. In multiplayer (e.g. four-player Commander) this lets you express things like "yield until that opponent's End Step" without affecting how you respond to the other opponents' end steps. + +**Cancelling:** +- Right-click (or long-press) the marker again to cancel it. +- Press **ESC** (desktop) to cancel any active marker. +- An enabled interrupt firing (see [Yield Interrupt Settings](#yield-interrupt-settings)) cancels the marker and hands priority back to you. + +**Re-targeting:** Right-clicking (or long-pressing) a different phase indicator while a marker is active moves the marker to the new cell. Only one marker is active at a time. + +## Yield Settings Menu + +The **Yield Settings** dialog is the central configuration UI for yield behavior. It's accessible from: +- **Desktop:** the cog button on the dock, the Game menu > **Yield Settings** entry, or Ctrl+Y. +- **Mobile:** Game menu > **Yield Options**. + +The dialog has three sections: + +### Yield Interrupt Settings + +Yield markers, end-of-turn yield, and the interruptible **Yield to stack** automatically cancel when important game events occur. The non-interruptible **Resolve entire stack** is exempt — its purpose is to watch the stack resolve to completion, so opponent spells hitting the stack do not cancel it. + +You can decide which game events interrupt a yield: + +| Interrupt | Default | Description | +|-----------|---------|-------------| +| **Attackers declared against you** | ON | Triggers when creatures attack you specifically (not when other players are attacked). | +| **Opponent casts any spell** | ON | Triggers on opponent spells and activated abilities (not triggered abilities). | +| **You or your permanents targeted** | OFF | Triggers when a spell/ability targets you or something you control. | +| **Mass removal spell cast** | OFF | Triggers when an opponent casts a board wipe (DestroyAll / DamageAll / SacrificeAll / ChangeZoneAll spell). | +| **Triggered abilities on stack** | OFF | Triggers when triggered abilities are on the stack. | +| **Cards revealed or choices made** | OFF | Triggers when reveal dialogs / non-trivial value notifications fire. | + +### Automatic Yield Suggestions + +When the system detects situations where you likely cannot take action, it can prompt you with a yield suggestion. Each suggestion type has a dropdown controlling its decline behavior: + +| Suggestion | When it appears | Suggested action | Decline scope options | +|------------|-----------------|------------------|-----------------------| +| **Can't respond to stack** | You have no instant-speed responses available | Yield to stack (interruptible — auto-pass until stack empties or an interrupt fires) | Never (default) / Always / Once per stack / Once per turn | +| **No actions available** | No playable cards or activatable abilities (not your turn, stack empty) | Yield to your next turn | Never (default) / Always / Once per turn | + +**Decline scope options:** +- **Never:** suggestion is disabled entirely (never shown). +- **Always:** suggestion re-appears on the next priority pass, even if just declined. +- **Once per stack:** declining suppresses the suggestion until the current stack resolves. A new stack will re-prompt. (Only available for "Can't respond to stack".) +- **Once per turn:** declining suppresses the suggestion for the rest of the current turn. + +In addition to the per-suggestion dropdowns, this section contains two global suppression toggles: + +- **Suppress on own turn** (default ON): suppress suggestions during your own turn, when you typically want to take actions. Suggestions are always suppressed on your first turn regardless of this setting, since you won't have any lands or mana yet. +- **Suppress immediately after yield ends** (default ON): suppress suggestions for one priority pass when a yield expires or is interrupted, giving you time to assess the game state before deciding whether to re-yield. + +### Speed Options + +- **Skip delay between phases:** skip Forge's default 200ms delay between each phase resolving. +- **Skip delay when stack resolves:** skip Forge's default 400ms delay between items on the stack resolving. + +## Troubleshooting + +### Yield marker doesn't appear when right-clicking / long-pressing a phase indicator +- Markers cannot be set during pre-game, mulligan, or cleanup phases. + +### Yield clears unexpectedly +- Check interrupt settings in the Yield Settings dialog. +- A marker also clears automatically the moment its target phase is reached. + +### Smart suggestions not appearing +- Verify the suggestion's decline scope is not set to "Never" in the Yield Settings dialog. +- Suggestions don't appear if you're already yielding. +- If you declined a suggestion, check the decline scope to understand when it will re-appear. + +## Network Play +- Each player controls their own yield preferences. Your yield marker, stack-yield state, interrupt settings, and decline-scope choices apply to you only and propagate across the network — they do not affect other players. The host does not impose its own preferences on connected clients. \ No newline at end of file diff --git a/forge-ai/src/main/java/forge/ai/AiCostDecision.java b/forge-ai/src/main/java/forge/ai/AiCostDecision.java index 7f694c1b51a..b8fd3532faa 100644 --- a/forge-ai/src/main/java/forge/ai/AiCostDecision.java +++ b/forge-ai/src/main/java/forge/ai/AiCostDecision.java @@ -199,8 +199,7 @@ public PaymentDecision visit(CostExile cost) { CardCollection valid = CardLists.getValidCards(player.getGame().getCardsIn(cost.getFrom().get(0)), typeCleaned, player, source, ability); CardCollection chosen = new CardCollection(); - CardLists.sortByCmcDesc(valid); - Collections.reverse(valid); + valid.sort(CardLists.CmcComparator); int totalCMC = 0; for (Card card : valid) { diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java new file mode 100644 index 00000000000..71998548943 --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -0,0 +1,75 @@ +package forge.ai; + +import forge.game.card.Card; +import forge.game.card.CardLists; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; +import org.tinylog.Logger; + +import java.util.stream.Collectors; + +// Heuristic: does the player have any playable action this priority window? +// Bounded by timeoutMs; returns true on expiry (false-positive — player is prompted). +public final class AvailableActions { + + private 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) { + return player.getCardsIn(zone).stream().sorted(CardLists.CmcComparator).collect(Collectors.toList()); + } + + 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-ai/src/main/java/forge/ai/ComputerUtil.java b/forge-ai/src/main/java/forge/ai/ComputerUtil.java index 2e25afe06db..1d984ae7a26 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtil.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtil.java @@ -589,9 +589,7 @@ public static CardCollection chooseCollectEvidence(final Player ai, CostCollectE if (CardLists.getTotalCMC(typeList) < amount) return null; - // FIXME: This is suboptimal, maybe implement a single comparator that'll take care of all of this? - CardLists.sortByCmcDesc(typeList); - Collections.reverse(typeList); + typeList.sort(CardLists.CmcComparator); // TODO AI needs some improvements here // What's the best way to choose evidence to collect? diff --git a/forge-ai/src/main/java/forge/ai/SpecialCardAi.java b/forge-ai/src/main/java/forge/ai/SpecialCardAi.java index 7dd343fd7b3..b9a9e76a3cf 100644 --- a/forge-ai/src/main/java/forge/ai/SpecialCardAi.java +++ b/forge-ai/src/main/java/forge/ai/SpecialCardAi.java @@ -1680,12 +1680,13 @@ public static AiAbilityDecision consider(final Player ai, final SpellAbility sa) return copy.getNetToughness() > 0; }) ); - CardLists.sortByCmcDesc(creaturesToGet); if (creaturesToGet.isEmpty()) { return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi); } + CardLists.sortByCmcDesc(creaturesToGet); + // pick the best creature that will stay on the battlefield Card best = creaturesToGet.getFirst(); for (Card c : creaturesToGet) { @@ -1742,7 +1743,7 @@ public static Card considerDiscardTarget(final Player ai) { // Cards in hand that are below the max CMC affordable by the AI CardCollection belowMaxCMC = CardLists.filter(creatsInHand, CardPredicates.lessCMC(numManaSrcs - 1)); - belowMaxCMC.sort(Collections.reverseOrder(CardLists.CmcComparatorInv)); + belowMaxCMC.sort(CardLists.CmcComparator); // Cards in hand that are above the max CMC affordable by the AI CardCollection aboveMaxCMC = CardLists.filter(creatsInHand, CardPredicates.greaterCMC(numManaSrcs + 1)); @@ -1788,7 +1789,7 @@ public static Card considerDiscardTarget(final Player ai) { } public static Card considerCardToGet(final Player ai, final SpellAbility sa) { - CardCollectionView creatsInLib = CardLists.filter(ai.getCardsIn(ZoneType.Library), CardPredicates.CREATURES); + CardCollection creatsInLib = CardLists.filter(ai.getCardsIn(ZoneType.Library), CardPredicates.CREATURES); if (creatsInLib.isEmpty()) { return null; } @@ -1805,13 +1806,12 @@ public static Card considerCardToGet(final Player ai, final SpellAbility sa) { } atTargetCMCInLib.sort(CardLists.CmcComparatorInv); - Card bestInLib = atTargetCMCInLib != null ? atTargetCMCInLib.getFirst() : null; + Card bestInLib = atTargetCMCInLib.getFirst(); if (bestInLib == null && ComputerUtil.isPlayingReanimator(ai)) { // For Reanimator, we don't mind grabbing the biggest thing possible to recycle it again with SotF later. - CardCollection creatsInLibByCMC = new CardCollection(creatsInLib); - creatsInLibByCMC.sort(CardLists.CmcComparatorInv); - return creatsInLibByCMC.getFirst(); + creatsInLib.sort(CardLists.CmcComparatorInv); + return creatsInLib.getFirst(); } return bestInLib; diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index b30b5dc0880..3424428d149 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1556,7 +1556,7 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } // Tutor for the first key card in the list, since the list should be in priority order - for(String keyName : keyCards) { + for (String keyName : keyCards) { CardCollection withKeyCard = CardLists.filter(fetchList, CardPredicates.nameEquals(keyName)); if (withKeyCard.isEmpty()) { continue; @@ -1567,14 +1567,9 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List // Does AI need a land? // The logic here seems wrong if the decider isn't the same as the player CardCollectionView hand = decider.getCardsIn(ZoneType.Hand); - if (!hand.anyMatch(CardPredicates.LANDS) && CardLists.count(decider.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS) < 4) { - boolean canCastSomething = false; - for (Card cardInHand : hand) { - canCastSomething = canCastSomething || ComputerUtilMana.hasEnoughManaSourcesToCast(cardInHand.getFirstSpellAbility(), decider); - } - if (!canCastSomething) { - c = basicManaFixing(decider, fetchList); - } + if (!hand.anyMatch(CardPredicates.LANDS) && CardLists.count(decider.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS) < 4 && + !hand.anyMatch(crd -> ComputerUtilMana.hasEnoughManaSourcesToCast(crd.getFirstSpellAbility(), decider))) { + c = basicManaFixing(decider, fetchList); } if (c == null) { if (fetchList.allMatch(CardPredicates.LANDS)) { @@ -1698,7 +1693,7 @@ private AiAbilityDecision doSacAndReturnFromGraveLogic(final Player ai, final Sp String definedSac = StringUtils.split(source.getSVar("AIPreference"), "$")[1]; CardCollection listToSac = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), definedSac, ai, source, sa); - listToSac.sort(Collections.reverseOrder(CardLists.CmcComparatorInv)); + listToSac.sort(CardLists.CmcComparator); CardCollection listToRet = CardLists.filter(ai.getCardsIn(ZoneType.Graveyard), CardPredicates.CREATURES); listToRet.sort(CardLists.CmcComparatorInv); @@ -1735,7 +1730,7 @@ private AiAbilityDecision doSacAndUpgradeLogic(final Player ai, final SpellAbili boolean anyCMC = !definedGoal.contains(".cmc"); CardCollection listToSac = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), definedSac, ai, source, sa); - listToSac.sort(!sacWorst ? CardLists.CmcComparatorInv : Collections.reverseOrder(CardLists.CmcComparatorInv)); + listToSac.sort(!sacWorst ? CardLists.CmcComparatorInv : CardLists.CmcComparator); for (Card sacCandidate : listToSac) { int sacCMC = sacCandidate.getCMC(); diff --git a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java index b8f4539eee2..6ada7a5c8de 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChooseCardAi.java @@ -15,7 +15,6 @@ import forge.util.Aggregates; import forge.util.IterableUtil; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -214,8 +213,7 @@ public Card chooseSingleCard(final Player ai, final SpellAbility sa, Iterable in, final in public static final Comparator ToughnessComparator = Comparator.comparingInt(Card::getNetToughness); public static final Comparator ToughnessComparatorInv = Comparator.comparingInt(Card::getNetToughness).reversed(); public static final Comparator PowerComparator = Comparator.comparingInt(Card::getNetCombatDamage); + public static final Comparator CmcComparator = Comparator.comparingInt(Card::getCMC); public static final Comparator CmcComparatorInv = Comparator.comparingInt(Card::getCMC).reversed(); public static final Comparator TextLenComparator = Comparator.comparingInt(a -> a.getView().getText().length()); 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 25224deefee..b24158c5b6f 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -208,11 +208,15 @@ public void setHasLost(final boolean val) { set(TrackableProperty.HasLost, val); } - public int getAvatarLifeDifference() { - return (int)get(TrackableProperty.AvatarLifeDifference); + public boolean hasAvailableActions() { + return get(TrackableProperty.HasAvailableActions); + } + public void setHasAvailableActions(boolean value) { + set(TrackableProperty.HasAvailableActions, value); } - public boolean wasAvatarLifeChanged() { - return (int)get(TrackableProperty.AvatarLifeDifference) != 0; + + public int getAvatarLifeDifference() { + return get(TrackableProperty.AvatarLifeDifference); } public void setAvatarLifeDifference(final int val) { set(TrackableProperty.AvatarLifeDifference, val); diff --git a/forge-game/src/main/java/forge/game/zone/MagicStack.java b/forge-game/src/main/java/forge/game/zone/MagicStack.java index 6efcc01cf8c..2665b112758 100644 --- a/forge-game/src/main/java/forge/game/zone/MagicStack.java +++ b/forge-game/src/main/java/forge/game/zone/MagicStack.java @@ -324,14 +324,6 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) { return; } - //cancel auto-pass for all opponents of activating player - //when a new non-triggered ability is put on the stack - if (!sp.isTrigger()) { - for (final Player p : activator.getOpponents()) { - p.getController().autoPassCancel(); - } - } - if (sp instanceof AbilityStatic || (sp.isTrigger() && sp.getTrigger().getOverridingAbility() instanceof AbilityStatic)) { AbilityUtils.resolve(sp); // AbilityStatic should do nothing below diff --git a/forge-game/src/main/java/forge/trackable/TrackableProperty.java b/forge-game/src/main/java/forge/trackable/TrackableProperty.java index b8c244139e1..06b3fc9fa7e 100644 --- a/forge-game/src/main/java/forge/trackable/TrackableProperty.java +++ b/forge-game/src/main/java/forge/trackable/TrackableProperty.java @@ -234,6 +234,7 @@ public enum TrackableProperty { HasPriority(TrackableTypes.BooleanType, FreezeMode.IgnoresFreeze), AvatarLifeDifference(TrackableTypes.IntegerType, FreezeMode.IgnoresFreeze), HasLost(TrackableTypes.BooleanType), + HasAvailableActions(TrackableTypes.BooleanType), //SpellAbility HostCard(TrackableTypes.CardViewType), diff --git a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java index 5a8a8d19702..eb3d7facf04 100644 --- a/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java +++ b/forge-gui-desktop/src/main/java/forge/control/KeyboardShortcuts.java @@ -22,11 +22,13 @@ import forge.gui.framework.EDocID; import forge.gui.framework.SDisplayUtil; import forge.localinstance.properties.ForgePreferences; +import forge.gamemodes.match.YieldController; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.AutoYieldStore.TriggerDecision; import forge.screens.home.settings.VSubmenuPreferences.KeyboardShortcutField; import forge.screens.match.CMatchUI; +import forge.screens.match.VYieldSettings; import forge.toolbox.special.CardZoomer; import forge.util.Localizer; import forge.view.KeyboardShortcutsDialog; @@ -128,7 +130,7 @@ public void actionPerformed(final ActionEvent e) { public void actionPerformed(final ActionEvent e) { if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } if (matchUI == null) { return; } - matchUI.getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } }; @@ -260,6 +262,25 @@ public void actionPerformed(final ActionEvent e) { } }; + final Action actYieldOptions = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + SwingUtilities.invokeLater(() -> new VYieldSettings(matchUI).showDialog()); + } + }; + + final Action actYieldAutoPass = new AbstractAction() { + @Override + public void actionPerformed(final ActionEvent e) { + if (!Singletons.getControl().getCurrentScreen().isMatchScreen()) { return; } + if (matchUI == null) { return; } + YieldController.toggleAutoPassOrStopAll(matchUI.getGameController()); + matchUI.getCDock().refreshAutoPassToggled(); + } + }; + final Localizer localizer = Localizer.getInstance(); //========== Instantiate shortcut objects and add to list. list.add(new Shortcut(FPref.SHORTCUT_SHOWSTACK, localizer.getMessage("lblSHORTCUT_SHOWSTACK"), actShowStack, am, im)); @@ -273,6 +294,8 @@ public void actionPerformed(final ActionEvent e) { list.add(new Shortcut(FPref.SHORTCUT_SHOWTARGETING, localizer.getMessage("lblSHORTCUT_SHOWTARGETING"), actTgtOverlay, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_YES, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_YES"), actAutoYieldAndYes, am, im)); list.add(new Shortcut(FPref.SHORTCUT_AUTOYIELD_ALWAYS_NO, localizer.getMessage("lblSHORTCUT_AUTOYIELD_ALWAYS_NO"), actAutoYieldAndNo, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_OPTIONS, localizer.getMessage("lblSHORTCUT_YIELD_OPTIONS"), actYieldOptions, am, im)); + list.add(new Shortcut(FPref.SHORTCUT_YIELD_AUTO_PASS, localizer.getMessage("lblSHORTCUT_YIELD_AUTO_PASS"), actYieldAutoPass, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_RECORD, localizer.getMessage("lblSHORTCUT_MACRO_RECORD"), actMacroRecord, am, im)); list.add(new Shortcut(FPref.SHORTCUT_MACRO_NEXT_ACTION, localizer.getMessage("lblSHORTCUT_MACRO_NEXT_ACTION"), actMacroNextAction, am, im)); list.add(new Shortcut(FPref.SHORTCUT_CARD_ZOOM, localizer.getMessage("lblSHORTCUT_CARD_ZOOM"), actZoomCard, am, im)); diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java b/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java index 0575fea6ac9..e2642a5468b 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/SResizingUtil.java @@ -48,7 +48,7 @@ public final class SResizingUtil { /** Minimum cell width. */ public static final int W_MIN = 100; /** Minimum cell height. */ - public static final int H_MIN = 75; + public static final int H_MIN = 50; private static final MouseListener MAD_RESIZE_X = new MouseAdapter() { @Override diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java index 1dab5bcd3bd..973d6163e0d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYieldsAndTriggers.java @@ -90,11 +90,11 @@ public VAutoYieldsAndTriggers(final CMatchUI matchUI) { listScroller = new FScrollPane(lstEntries, true); chkDisableYields = new FCheckBox(Localizer.getInstance().getMessage("lblDisableAllAutoYields"), - matchUI.getGameController().getDisableAutoYields()); + matchUI.getGameController().getYieldController().getDisableAutoYields()); chkDisableYields.addChangeListener(e -> matchUI.getGameController().setDisableAutoYields(chkDisableYields.isSelected())); chkDisableTriggers = new FCheckBox(Localizer.getInstance().getMessage("lblDisableAllAutoTriggers"), - matchUI.getGameController().getDisableAutoTriggers()); + matchUI.getGameController().getYieldController().getDisableAutoTriggers()); chkDisableTriggers.addChangeListener(e -> matchUI.getGameController().setDisableAutoTriggers(chkDisableTriggers.isSelected())); btnOk = new FButton(Localizer.getInstance().getMessage("lblOK")); diff --git a/forge-gui-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..5225eb22fa7 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VYieldSettings.java @@ -0,0 +1,270 @@ +package forge.screens.match; + +import forge.Singletons; +import forge.gui.UiCommand; +import forge.interfaces.IGameController; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; + +import java.util.Set; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.toolbox.FButton; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FLabel; +import forge.toolbox.FTextField; +import forge.util.Localizer; +import forge.view.FDialog; + +import javax.swing.JSeparator; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; +import javax.swing.text.PlainDocument; + +/** + * Dialog for configuring yield interrupt conditions and automatic suggestions. + */ +@SuppressWarnings("serial") +public class VYieldSettings extends FDialog { + private static final int PADDING = 10; + private static final int ROW_HEIGHT = 24; + private static final int SECTION_GAP = 12; + private static final int BUTTON_WIDTH = 100; + private static final int BUTTON_HEIGHT = 26; + private static final int DROPDOWN_WIDTH = 120; + private static final int BODY_FONT_SIZE = 12; + + private static void setBodyFont(java.awt.Component c) { + c.setFont(c.getFont().deriveFont((float) BODY_FONT_SIZE)); + } + + private final CMatchUI matchUI; + + public VYieldSettings(CMatchUI matchUI) { + super(); + this.matchUI = matchUI; + final Localizer localizer = Localizer.getInstance(); + final ForgePreferences prefs = FModel.getPreferences(); + + setTitle(localizer.getMessage("lblYieldSettings")); + + int width = Math.min(Singletons.getView().getFrame().getWidth() * 2 / 3, 480); + int w = width - 2 * PADDING; + int x = PADDING; + int y = PADDING; + + FLabel lblInterrupt = new FLabel.Builder().text(localizer.getMessage("lblInterruptSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblInterrupt, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT - 4; + + y = addDescription(x, y, w, localizer.getMessage("lblInterruptSettingsDesc")); + + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnAttackers"), FPref.YIELD_INTERRUPT_ON_ATTACKERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTargeting"), FPref.YIELD_INTERRUPT_ON_TARGETING, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnMassRemoval"), FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnOpponentSpell"), FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnTriggers"), FPref.YIELD_INTERRUPT_ON_TRIGGERS, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblInterruptOnReveal"), FPref.YIELD_INTERRUPT_ON_REVEAL, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblAutoPassRespectsInterrupts"), FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, prefs); + + y += SECTION_GAP; + JSeparator sep = new JSeparator(); + add(sep, x, y, w, 2); + y += 2 + SECTION_GAP; + + FLabel lblSuggestions = new FLabel.Builder().text(localizer.getMessage("lblAutomaticSuggestions")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSuggestions, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT - 4; + + y = addDescription(x, y, w, localizer.getMessage("lblAutomaticSuggestionsDesc")); + + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestStackYield"), + FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, + SuggestionType.STACK_YIELD.allowedScopes(), + prefs); + + y = addLabelWithDropdown(x, y, w, + localizer.getMessage("lblSuggestNoActions"), + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, + SuggestionType.NO_ACTIONS.allowedScopes(), + prefs); + + y += SECTION_GAP; + + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressOnOwnTurn"), FPref.YIELD_SUPPRESS_ON_OWN_TURN, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSuppressAfterYield"), FPref.YIELD_SUPPRESS_AFTER_END, prefs); + + y += SECTION_GAP; + JSeparator sep2 = new JSeparator(); + add(sep2, x, y, w, 2); + y += 2 + SECTION_GAP; + + FLabel lblSpeed = new FLabel.Builder().text(localizer.getMessage("lblSpeedSettings")) + .fontStyle(java.awt.Font.BOLD).fontSize(14).build(); + add(lblSpeed, x, y, w, ROW_HEIGHT); + y += ROW_HEIGHT + 2; + + y = addTimeoutField(x, y, w, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipPhaseDelay"), FPref.YIELD_SKIP_PHASE_DELAY, prefs); + y = addCheckbox(x, y, w, localizer.getMessage("lblSkipResolveDelay"), FPref.YIELD_SKIP_RESOLVE_DELAY, prefs); + + y += SECTION_GAP; + + FButton btnOk = new FButton(localizer.getMessage("lblOK")); + btnOk.setCommand((UiCommand) () -> setVisible(false)); + int btnX = (width - BUTTON_WIDTH) / 2; + add(btnOk, btnX, y, BUTTON_WIDTH, BUTTON_HEIGHT); + y += BUTTON_HEIGHT + PADDING; + + this.pack(); + this.setSize(width, y + 3 * PADDING); + } + + private int addDescription(int x, int y, int w, String text) { + String html = "
" + text + "
"; + FLabel desc = new FLabel.Builder().text(html) + .fontStyle(java.awt.Font.ITALIC).fontSize(13) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + int h = ROW_HEIGHT * 3 / 2; + add(desc, x, y, w, h); + return y + h + 2; + } + + private int addCheckbox(int x, int y, int w, String label, FPref pref, ForgePreferences prefs) { + FCheckBox cb = new FCheckBox(label, prefs.getPrefBoolean(pref)); + setBodyFont(cb); + cb.addActionListener(e -> { + boolean value = cb.isSelected(); + prefs.setPref(pref, value); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) { + controller.setYieldPref(pref, String.valueOf(value)); + } + }); + add(cb, x, y, w, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private int addLabelWithDropdown(int x, int y, int w, String label, + FPref scopePref, Set options, ForgePreferences prefs) { + DeclineScope[] optArr = options.toArray(new DeclineScope[0]); + int lblWidth = w - DROPDOWN_WIDTH - PADDING; + Localizer loc = Localizer.getInstance(); + FLabel lbl = new FLabel.Builder().text(label).fontSize(BODY_FONT_SIZE) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + FComboBox combo = new FComboBox<>(); + setBodyFont(combo); + java.awt.Dimension dropSize = new java.awt.Dimension(DROPDOWN_WIDTH, ROW_HEIGHT); + combo.setPreferredSize(dropSize); + combo.setMinimumSize(dropSize); + combo.setMaximumSize(dropSize); + for (DeclineScope opt : optArr) { + combo.addItem(loc.getMessage(opt.labelKey())); + } + DeclineScope current = DeclineScope.fromPref(prefs.getPref(scopePref)); + for (int i = 0; i < optArr.length; i++) { + if (optArr[i] == current) { + combo.setSelectedIndex(i); + break; + } + } + combo.addActionListener(e -> { + int idx = combo.getSelectedIndex(); + if (idx >= 0 && idx < optArr.length) { + String value = optArr[idx].name(); + prefs.setPref(scopePref, value); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) controller.setYieldPref(scopePref, value); + } + }); + add(combo, x + w - DROPDOWN_WIDTH, y, DROPDOWN_WIDTH, ROW_HEIGHT); + + return y + ROW_HEIGHT; + } + + private int addTimeoutField(int x, int y, int w, ForgePreferences prefs) { + final Localizer loc = Localizer.getInstance(); + final int fieldWidth = DROPDOWN_WIDTH; + int lblWidth = w - fieldWidth - PADDING; + String tooltip = loc.getMessage("lblAutoPassBudgetDesc"); + FLabel lbl = new FLabel.Builder().text(loc.getMessage("lblAutoPassBudgetLabel")) + .fontSize(BODY_FONT_SIZE) + .fontAlign(javax.swing.SwingConstants.LEFT).build(); + lbl.setToolTipText(tooltip); + add(lbl, x, y, lblWidth, ROW_HEIGHT); + + int current = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + FTextField field = new FTextField.Builder() + .ghostText(loc.getMessage("lblDynamic")) + .tooltip(tooltip) + .text(current > 0 ? String.valueOf(current) : "") + .build(); + setBodyFont(field); + ((PlainDocument) field.getDocument()).setDocumentFilter(new DigitsOnlyFilter(4)); + field.getDocument().addDocumentListener(new DocumentListener() { + private void save() { + String text = field.getText(); + int value; + try { + value = (text == null || text.isEmpty()) ? 0 : Integer.parseInt(text); + } catch (NumberFormatException nfe) { value = 0; } + if (value > 9999) value = 9999; + String str = String.valueOf(value); + prefs.setPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); + prefs.save(); + IGameController controller = matchUI == null ? null : matchUI.getGameController(); + if (controller != null) controller.setYieldPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, str); + } + @Override public void insertUpdate(DocumentEvent e) { save(); } + @Override public void removeUpdate(DocumentEvent e) { save(); } + @Override public void changedUpdate(DocumentEvent e) { save(); } + }); + add(field, x + w - fieldWidth, y, fieldWidth, ROW_HEIGHT); + return y + ROW_HEIGHT; + } + + private static final class DigitsOnlyFilter extends DocumentFilter { + private final int maxLength; + DigitsOnlyFilter(int maxLength) { this.maxLength = maxLength; } + @Override + public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException { + if (string == null) return; + String filtered = scrub(string); + if (filtered.isEmpty()) return; + if (fb.getDocument().getLength() + filtered.length() > maxLength) return; + super.insertString(fb, offset, filtered, attr); + } + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException { + String filtered = text == null ? "" : scrub(text); + int newLength = fb.getDocument().getLength() - length + filtered.length(); + if (newLength > maxLength) return; + super.replace(fb, offset, length, filtered, attrs); + } + private static String scrub(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isDigit(c)) sb.append(c); + } + return sb.toString(); + } + } + + public void showDialog() { + setVisible(true); + dispose(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java index 320beb89265..ea77395e351 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CDock.java @@ -23,14 +23,14 @@ import com.google.common.primitives.Ints; import forge.Singletons; -import forge.gui.SOverlayUtils; +import forge.gamemodes.match.YieldController; import forge.gui.UiCommand; import forge.gui.framework.ICDoc; -import forge.gui.framework.SLayoutIO; import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; import forge.model.FModel; import forge.screens.match.CMatchUI; +import forge.screens.match.VYieldSettings; import forge.screens.match.views.VDock; import forge.toolbox.FSkin; import forge.util.Localizer; @@ -62,7 +62,7 @@ public VDock getView() { * End turn. */ public void endTurn() { - matchUI.getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } /** @@ -129,14 +129,23 @@ public void initialize() { refreshArcStateDisplay(); view.getBtnConcede().setCommand((UiCommand) matchUI::concede); - view.getBtnSettings().setCommand((UiCommand) SOverlayUtils::showOverlay); + view.getBtnSettings().setCommand((UiCommand) () -> new VYieldSettings(matchUI).showDialog()); view.getBtnEndTurn().setCommand((UiCommand) this::endTurn); + view.getBtnAutoPass().setCommand((UiCommand) this::toggleAutoPass); view.getBtnViewDeckList().setCommand((UiCommand) matchUI::viewDeckList); - view.getBtnRevertLayout().setCommand((UiCommand) SLayoutIO::revertLayout); - view.getBtnOpenLayout().setCommand((UiCommand) SLayoutIO::openLayout); - view.getBtnSaveLayout().setCommand((UiCommand) SLayoutIO::saveLayout); view.getBtnAlphaStrike().setCommand((UiCommand) () -> matchUI.getGameController().alphaStrike()); view.getBtnTargeting().setCommand((UiCommand) this::toggleTargeting); + + refreshAutoPassToggled(); + } + + private void toggleAutoPass() { + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + refreshAutoPassToggled(); + } + + public void refreshAutoPassToggled() { + view.getBtnAutoPass().setToggled(FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); } /* (non-Javadoc) @@ -144,6 +153,7 @@ public void initialize() { */ @Override public void update() { + refreshAutoPassToggled(); } } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 39484bdb0e0..65eda0b6063 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -5,13 +5,17 @@ import javax.swing.ButtonGroup; import javax.swing.JMenu; +import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; +import javax.swing.event.MenuEvent; +import javax.swing.event.MenuListener; import com.google.common.primitives.Ints; import forge.control.KeyboardShortcuts; +import forge.gamemodes.match.YieldController; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.localinstance.skin.FSkinProp; @@ -19,6 +23,7 @@ import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.screens.match.VAutoYieldsAndTriggers; +import forge.screens.match.VYieldSettings; import forge.screens.match.views.VField; import forge.screens.match.controllers.CDock.ArcState; import forge.toolbox.FSkin.SkinIcon; @@ -57,8 +62,18 @@ public JMenu getMenu() { menu.add(getMenuItem_TokensSeparateRow()); menu.add(getMenuItem_SeparateCombatStacks()); menu.add(getMenuItem_AutoYieldsAndTriggers()); + menu.add(getMenuItem_YieldSettings()); + final SkinnedCheckBoxMenuItem autoPassItem = getMenuItem_AutoPass(); + menu.add(autoPassItem); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); + menu.addMenuListener(new MenuListener() { + @Override public void menuSelected(final MenuEvent e) { + autoPassItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + } + @Override public void menuDeselected(final MenuEvent e) {} + @Override public void menuCanceled(final MenuEvent e) {} + }); return menu; } @@ -109,11 +124,11 @@ private SkinnedMenuItem getMenuItem_EndTurn() { } private ActionListener getEndTurnAction() { - return e -> matchUI.getGameController().passPriorityUntilEndOfTurn(); + return e -> YieldController.endTurn(matchUI.getGameController(), matchUI.getCurrentPlayer()); } /** Sets a menu item's accelerator display from a shortcut preference. */ - private static void setAcceleratorFromPref(final SkinnedMenuItem menuItem, final FPref pref) { + private static void setAcceleratorFromPref(final JMenuItem menuItem, final FPref pref) { final KeyStroke ks = KeyboardShortcuts.getKeyStrokeForPref(pref); if (ks != null) { menuItem.setAccelerator(ks); @@ -183,6 +198,27 @@ private SkinnedMenuItem getMenuItem_AutoYieldsAndTriggers() { return menuItem; } + private SkinnedMenuItem getMenuItem_YieldSettings() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedMenuItem menuItem = new SkinnedMenuItem(localizer.getMessage("lblYieldSettings")); + menuItem.setIcon((showIcons ? MenuUtil.getMenuIcon(FSkinProp.ICO_SETTINGS) : null)); + menuItem.addActionListener(e -> new VYieldSettings(matchUI).showDialog()); + return menuItem; + } + + private SkinnedCheckBoxMenuItem getMenuItem_AutoPass() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("lblEnableAutoPass")); + setAcceleratorFromPref(menuItem, FPref.SHORTCUT_YIELD_AUTO_PASS); + menuItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + menuItem.addActionListener(e -> { + YieldController.toggleAutoPassNoActions(matchUI.getGameController()); + matchUI.getCDock().refreshAutoPassToggled(); + menuItem.setState(prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS)); + }); + return menuItem; + } + private SkinnedMenuItem getMenuItem_ViewDeckList() { final Localizer localizer = Localizer.getInstance(); final SkinnedMenuItem menuItem = new SkinnedMenuItem(localizer.getMessage("lblDeckList")); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java index 9714141b927..d8d035c9d3f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VDock.java @@ -53,12 +53,10 @@ public class VDock implements IVDoc { private final DragTab tab = new DragTab(localizer.getMessage("lblDock")); // Dock button instances private final DockButton btnConcede = new DockButton(FSkin.getIcon(FSkinProp.ICO_CONCEDE), localizer.getMessage("lblConcedeGame")); - private final DockButton btnSettings = new DockButton(FSkin.getIcon(FSkinProp.ICO_SETTINGS), localizer.getMessage("lblGameSettings")); + private final DockButton btnSettings = new DockButton(FSkin.getIcon(FSkinProp.ICO_SETTINGS), localizer.getMessage("lblYieldSettings")); private final DockButton btnEndTurn = new DockButton(FSkin.getIcon(FSkinProp.ICO_ENDTURN), localizer.getMessage("lblEndTurn")); private final DockButton btnViewDeckList = new DockButton(FSkin.getIcon(FSkinProp.ICO_DECKLIST), localizer.getMessage("lblViewDeckList")); - private final DockButton btnRevertLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_REVERTLAYOUT), localizer.getMessage("lblRevertLayout")); - private final DockButton btnOpenLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_OPENLAYOUT), localizer.getMessage("lblOpenLayout")); - private final DockButton btnSaveLayout = new DockButton(FSkin.getIcon(FSkinProp.ICO_SAVELAYOUT), localizer.getMessage("lblSaveLayout")); + private final DockButton btnAutoPass = new DockButton(FSkin.getIcon(FSkinProp.ICO_AUTOPASS), localizer.getMessage("lblYieldBtnAutoPassTooltip")); private final DockButton btnAlphaStrike = new DockButton(FSkin.getIcon(FSkinProp.ICO_ALPHASTRIKE), localizer.getMessage("lblAlphaStrike")); private final FLabel btnTargeting = new FLabel.Builder().icon(FSkin.getIcon(FSkinProp.ICO_ARCSOFF)) .hoverable(true).iconInBackground(true).iconScaleFactor(1.0).build(); @@ -82,19 +80,15 @@ public VDock(final CDock controller) { public void populate() { btnTargeting.setFocusable(false); // don't let the targeting arc switcher get focus final JPanel pnl = parentCell.getBody(); - // Mig layout does not support wrapping! - // http://stackoverflow.com/questions/5715833/how-do-you-make-miglayout-behave-like-wrap-layout pnl.setLayout(new FlowLayout(FlowLayout.CENTER, 10, 10)); - pnl.add(btnConcede); - //pnl.add(btnSettings); + pnl.add(btnAutoPass); + pnl.add(btnSettings); pnl.add(btnEndTurn); - pnl.add(btnViewDeckList); - pnl.add(btnRevertLayout); - pnl.add(btnOpenLayout); - pnl.add(btnSaveLayout); pnl.add(btnAlphaStrike); pnl.add(btnTargeting); + pnl.add(btnViewDeckList); + pnl.add(btnConcede); } /* (non-Javadoc) @@ -155,16 +149,8 @@ public DockButton getBtnViewDeckList() { return btnViewDeckList; } - public DockButton getBtnRevertLayout() { - return btnRevertLayout; - } - - public DockButton getBtnOpenLayout() { - return btnOpenLayout; - } - - public DockButton getBtnSaveLayout() { - return btnSaveLayout; + public DockButton getBtnAutoPass() { + return btnAutoPass; } public DockButton getBtnAlphaStrike() { @@ -184,9 +170,11 @@ public FLabel getBtnTargeting() { public class DockButton extends SkinnedLabel implements ILocalRepaint { private final SkinImage img; private final SkinColor hoverBG = FSkin.getColor(FSkin.Colors.CLR_HOVER); + private final Color toggledBG = new Color(218, 165, 32); private final Color defaultBG = new Color(0, 0, 0, 0); private final Color defaultBorderColor = new Color(0, 0, 0, 0); private UiCommand command; + private boolean toggled; private int w, h; /** @@ -233,6 +221,11 @@ public void setCommand(UiCommand command0) { this.command = command0; } + public void setToggled(final boolean t) { + this.toggled = t; + repaintSelf(); + } + @Override public void repaintSelf() { final Dimension d = getSize(); @@ -248,10 +241,15 @@ public void repaintSelf() { public void paintComponent(final Graphics g) { this.w = this.getWidth(); this.h = this.getHeight(); - g.setColor(this.getBackground()); + final boolean highlighted = this.toggled || this.getSkin().getBackground() == this.hoverBG; + if (this.toggled) { + g.setColor(this.toggledBG); + } else { + g.setColor(this.getBackground()); + } g.fillRect(0, 0, this.w, this.h); - if (this.getSkin().getBackground() == this.hoverBG) { + if (highlighted) { FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_BORDERS)); } else { diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index fa495026e6d..d43abed3bb3 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -79,6 +79,7 @@ public void keyPressed(final KeyEvent e) { && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { btnCancel.doClick(); + e.consume(); } } }; 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 2b67ab9a29e..469ad752db1 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 @@ -300,6 +300,7 @@ private final class AbilityMenu extends JPopupMenu { private final JCheckBoxMenuItem jmiAutoYield; private final JCheckBoxMenuItem jmiAlwaysYes; private final JCheckBoxMenuItem jmiAlwaysNo; + private final JMenuItem jmiYieldToStack; private final JMenuItem jmiYieldToEntireStack; private StackItemView item; @@ -336,11 +337,20 @@ public AbilityMenu(){ }); add(jmiAlwaysNo); + jmiYieldToStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToStack")); + jmiYieldToStack.addActionListener(arg0 -> { + final PlayerView local = controller.getMatchUI().getCurrentPlayer(); + if (local == null) return; + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true, true)); + controller.getMatchUI().getGameController().passPriority(); + }); + add(jmiYieldToStack); + jmiYieldToEntireStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToEntireStack")); jmiYieldToEntireStack.addActionListener(arg0 -> { final PlayerView local = controller.getMatchUI().getCurrentPlayer(); if (local == null) return; - controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true)); + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true, false)); controller.getMatchUI().getGameController().passPriority(); }); add(jmiYieldToEntireStack); diff --git a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java index 836180ddadc..05b43c4b45c 100644 --- a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java +++ b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java @@ -818,7 +818,7 @@ void updateSleeve(final int index, final int sleeveIndex) { } void setReady(final int index, final boolean ready) { - if (lobby.isAllowNetworking()){ + if (lobby.isAllowNetworking()) { updateDeck(index); fireReady(index, ready); return; @@ -843,11 +843,8 @@ void setDevMode(final int index) { int playerCount = lobby.getNumberOfSlots(); // clear ready for everyone for (int i = 0; i < playerCount; i++) { - final PlayerPanel panel = playerPanels.get(i); - final boolean wasReady = panel.isReady(); - panel.setIsReady(false); - if (wasReady && playerChangeListener != null) { - playerChangeListener.update(i, UpdateLobbyPlayerEvent.isReadyUpdate(false)); + if (playerPanels.get(i).isReady()) { + fireReady(i, false); } } } @@ -856,7 +853,7 @@ void firePlayerChangeListener(final int index) { playerChangeListener.update(index, getSlot(index)); } } - void fireReady(final int index, boolean ready){ + void fireReady(final int index, boolean ready) { playerPanels.get(index).setIsReady(ready); if (playerChangeListener != null) { playerChangeListener.update(index, UpdateLobbyPlayerEvent.isReadyUpdate(ready)); diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 4badae1f341..c10bd314bed 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -39,6 +39,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldController; import forge.gui.GuiBase; import forge.interfaces.IGameController; import forge.localinstance.properties.ForgePreferences; @@ -370,31 +371,29 @@ protected void drawOverlay(Graphics g) { } if (gameMenu != null) { - if (gameMenu.getChildCount() > 1) { - 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); - } - } else { - gameMenu.getChildAt(0).setEnabled(false); - gameMenu.getChildAt(1).setEnabled(false); - if (!Forge.isMobileAdventureMode) { - gameMenu.getChildAt(2).setEnabled(false); - gameMenu.getChildAt(3).setEnabled(true); - } + // Index trailing items from end — entries inserted before Settings don't shift them + int n = gameMenu.getChildCount(); + if (n > 1) { + int idxConcede = 0; + int idxAutoYields = 1; + int idxSettings = n - 2; // Settings is second-from-last + int idxShowWinLose = n - 1; // Show Win/Lose is last + boolean gameOver = viewWinLose != null; + boolean canSwitch = !gameOver && !game.isMulligan(); + gameMenu.getChildAt(idxConcede).setEnabled(canSwitch); + gameMenu.getChildAt(idxAutoYields).setEnabled(canSwitch); + if (!Forge.isMobileAdventureMode) { + gameMenu.getChildAt(idxSettings).setEnabled(canSwitch); + gameMenu.getChildAt(idxShowWinLose).setEnabled(gameOver); } } } - if (devMenu != null) { - if (devMenu.isVisible()) { - try { - //rollbackphase enable -- todo limit by gametype? - devMenu.getChildAt(2).setEnabled(game.getPlayers().size() == 2 && game.getStack().size() == 0 && !GuiBase.isNetPlay(MatchController.instance) && game.getPhase().isMain() && !game.getPlayerTurn().isAI()); - } catch (Exception e) {/*NPE when the game hasn't started yet and you click dev mode*/} - } + + if (devMenu != null && devMenu.isVisible()) { + try { + //rollbackphase enable -- todo limit by gametype? + devMenu.getChildAt(2).setEnabled(game.getPlayers().size() == 2 && game.getStack().size() == 0 && !GuiBase.isNetPlay(MatchController.instance) && game.getPhase().isMain() && !game.getPlayerTurn().isAI()); + } catch (Exception e) {/*NPE when the game hasn't started yet and you click dev mode*/} } if (activeEffect != null) { @@ -661,7 +660,7 @@ public boolean keyDown(int keyCode) { break; case Keys.E: //end turn on Ctrl+E on Android, E when running on desktop if (KeyInputAdapter.isCtrlKeyDown() || GuiBase.getInterface().isRunningOnDesktop()) { - getGameController().passPriorityUntilEndOfTurn(); + YieldController.endTurn(getGameController(), MatchController.instance.getCurrentPlayer()); return true; } break; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java b/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java index 39e8fb76d12..0e553e95556 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VAutoYieldsAndTriggers.java @@ -65,10 +65,10 @@ protected boolean allowDefaultItemWrap() { } }); chkDisableYields = add(new FCheckBox(Forge.getLocalizer().getMessage("lblDisableAllAutoYields"), - MatchController.instance.getGameController().getDisableAutoYields())); + MatchController.instance.getGameController().getYieldController().getDisableAutoYields())); chkDisableYields.setCommand(e -> MatchController.instance.getGameController().setDisableAutoYields(chkDisableYields.isSelected())); chkDisableTriggers = add(new FCheckBox(Forge.getLocalizer().getMessage("lblDisableAllAutoTriggers"), - MatchController.instance.getGameController().getDisableAutoTriggers())); + MatchController.instance.getGameController().getYieldController().getDisableAutoTriggers())); chkDisableTriggers.setCommand(e -> MatchController.instance.getGameController().setDisableAutoTriggers(chkDisableTriggers.isSelected())); initButton(0, Forge.getLocalizer().getMessage("lblOK"), e -> hide()); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java b/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java index 0cd8d1da1d6..22e63cd778a 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VAvatar.java @@ -125,7 +125,7 @@ public void draw(Graphics g) { float h = isHovered() ? getWidth()/16f+getHeight() : getHeight(); if (avatarAnimation != null && MatchController.instance.getGameView() != null && !MatchController.instance.getGameView().isMatchOver()) { - if (player.wasAvatarLifeChanged()) { + if (player.getAvatarLifeDifference() != 0) { avatarAnimation.start(); avatarAnimation.drawAvatar(g, image, 0, 0, w, h); } else { diff --git a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java index 7a1842b0b77..835889acbae 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VGameMenu.java @@ -2,8 +2,11 @@ import forge.Forge; import forge.assets.FSkinImage; +import forge.gamemodes.match.YieldController; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.menu.FDropDownMenu; import forge.menu.FMenuItem; +import forge.model.FModel; import forge.screens.match.MatchController; import forge.screens.settings.SettingsScreen; import forge.toolbox.FEvent; @@ -16,7 +19,6 @@ public VGameMenu() { @Override protected void buildMenu() { - addItem(new FMenuItem(MatchController.instance.getConcedeCaption(), FSkinImage.CONCEDE, e -> ThreadUtil.invokeInGameThread(MatchController.instance::concede) )); @@ -36,13 +38,13 @@ public void handleEvent(FEvent e) { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblAutoYieldsAndTriggers"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, new FEventHandler() { @Override public void handleEvent(FEvent e) { - final boolean autoYieldsDisabled = MatchController.instance.getGameController().getDisableAutoYields(); + final boolean autoYieldsDisabled = MatchController.instance.getGameController().getYieldController().getDisableAutoYields(); final VAutoYieldsAndTriggers dialog = new VAutoYieldsAndTriggers() { @Override public void setVisible(boolean b0) { super.setVisible(b0); if (!b0) { - if (autoYieldsDisabled && !MatchController.instance.getGameController().getDisableAutoYields()) { + if (autoYieldsDisabled && !MatchController.instance.getGameController().getYieldController().getDisableAutoYields()) { //if re-enabling auto-yields, auto-yield to current ability on stack if applicable if (MatchController.instance.getGameView().peekStack() != null) { final String key = MatchController.instance.getGameView().peekStack().getKey(); @@ -61,6 +63,13 @@ public void setVisible(boolean b0) { dialog.show(); } })); + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldSettings"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { + new VYieldOptions().show(); + })); + String autoPassLabel = Forge.getLocalizer().getMessage(FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS) ? "lblYieldBtnAutoPassOn" : "lblYieldBtnAutoPass"); + addItem(new FMenuItem(autoPassLabel, Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { + YieldController.toggleAutoPassNoActions(MatchController.instance.getGameController()); + })); 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/VStack.java b/forge-gui-mobile/src/forge/screens/match/views/VStack.java index 67788b50d75..2061b13d365 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VStack.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VStack.java @@ -318,10 +318,16 @@ protected void buildMenu() { } } } + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToStack"), + Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, + e -> { + controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true, true)); + controller.passPriority(); + })); addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToEntireStack"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { - controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true)); + controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true, false)); controller.passPriority(); })); addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); diff --git a/forge-gui-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..33706300466 --- /dev/null +++ b/forge-gui-mobile/src/forge/screens/match/views/VYieldOptions.java @@ -0,0 +1,286 @@ +package forge.screens.match.views; + +import com.badlogic.gdx.utils.Align; + +import forge.Forge; +import forge.assets.FSkinColor; +import forge.assets.FSkinColor.Colors; +import forge.assets.FSkinFont; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; +import forge.interfaces.IGameController; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.screens.match.MatchController; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; +import forge.toolbox.FDialog; +import forge.toolbox.FLabel; +import forge.toolbox.FOptionPane; +import forge.toolbox.FScrollPane; +import forge.toolbox.FTextField; +import forge.util.TextBounds; +import forge.util.Utils; + +public class VYieldOptions extends FDialog { + + private static final FSkinFont DESC_FONT = FSkinFont.get(12); + private static final FSkinFont BODY_FONT = FSkinFont.get(11); + private static final FSkinColor DESC_COLOR = FSkinColor.get(Colors.CLR_TEXT).alphaColor(0.55f); + + private final FScrollPane scroller; + + private final FLabel hdrInterrupts; + private final FLabel descInterrupts; + private final FCheckBox chkInterruptAttackers; + private final FCheckBox chkInterruptTargeting; + private final FCheckBox chkInterruptMassRemoval; + private final FCheckBox chkInterruptOpponentSpell; + private final FCheckBox chkInterruptTriggers; + private final FCheckBox chkInterruptReveal; + private final FCheckBox chkAutoPassRespectsInterrupts; + + private final FLabel hdrSuggestions; + private final FLabel descSuggestions; + private final FLabel lblStackScope; + private final FComboBox cboStackScope; + private final FLabel lblNoActionsScope; + private final FComboBox cboNoActionsScope; + + private final FCheckBox chkSuppressOwnTurn; + private final FCheckBox chkSuppressAfterYield; + + private final FLabel hdrSpeed; + private final FLabel lblBudget; + private final FLabel descBudget; + private final FTextField txtBudgetMs; + private final FCheckBox chkSkipPhaseDelay; + private final FCheckBox chkSkipResolveDelay; + + public VYieldOptions() { + super(Forge.getLocalizer().getMessage("lblYieldSettings"), 1); + final IGameController ctrl = MatchController.instance.getGameController(); + final ForgePreferences prefs = FModel.getPreferences(); + + scroller = add(new FScrollPane() { + @Override + protected ScrollBounds layoutAndGetScrollBounds(float visibleWidth, float visibleHeight) { + return layoutScrollerContent(visibleWidth); + } + }); + + hdrInterrupts = scroller.add(headerLabel("lblInterruptSettings")); + descInterrupts = scroller.add(descriptionLabel("lblInterruptSettingsDesc")); + chkInterruptAttackers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnAttackers"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS))); + chkInterruptTargeting = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTargeting"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING))); + chkInterruptMassRemoval = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnMassRemoval"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL))); + chkInterruptOpponentSpell = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnOpponentSpell"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL))); + chkInterruptTriggers = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnTriggers"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS))); + chkInterruptReveal = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblInterruptOnReveal"), ctrl.getYieldController().getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL))); + chkAutoPassRespectsInterrupts = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblAutoPassRespectsInterrupts"), ctrl.getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS))); + + chkInterruptAttackers.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_ATTACKERS, chkInterruptAttackers.isSelected())); + chkInterruptTargeting.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_TARGETING, chkInterruptTargeting.isSelected())); + chkInterruptMassRemoval.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, chkInterruptMassRemoval.isSelected())); + chkInterruptOpponentSpell.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, chkInterruptOpponentSpell.isSelected())); + chkInterruptTriggers.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_TRIGGERS, chkInterruptTriggers.isSelected())); + chkInterruptReveal.setCommand(e -> persistBool(ctrl, FPref.YIELD_INTERRUPT_ON_REVEAL, chkInterruptReveal.isSelected())); + chkAutoPassRespectsInterrupts.setCommand(e -> persistBool(ctrl, FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, chkAutoPassRespectsInterrupts.isSelected())); + + hdrSuggestions = scroller.add(headerLabel("lblAutomaticSuggestions")); + descSuggestions = scroller.add(descriptionLabel("lblAutomaticSuggestionsDesc")); + lblStackScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestStackYield")) + .align(Align.left) + .build()); + cboStackScope = scroller.add(new FComboBox()); + DeclineScope[] stackScopes = SuggestionType.STACK_YIELD.allowedScopes().toArray(new DeclineScope[0]); + for (DeclineScope s : stackScopes) cboStackScope.addItem(Forge.getLocalizer().getMessage(s.labelKey())); + cboStackScope.setSelectedIndex(scopeIndex(stackScopes, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD))); + cboStackScope.setDropDownChangeHandler(e -> persistScope(ctrl, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, stackScopes, cboStackScope.getSelectedIndex())); + + lblNoActionsScope = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblSuggestNoActions")) + .align(Align.left) + .build()); + cboNoActionsScope = scroller.add(new FComboBox()); + DeclineScope[] noActionsScopes = SuggestionType.NO_ACTIONS.allowedScopes().toArray(new DeclineScope[0]); + for (DeclineScope s : noActionsScopes) cboNoActionsScope.addItem(Forge.getLocalizer().getMessage(s.labelKey())); + cboNoActionsScope.setSelectedIndex(scopeIndex(noActionsScopes, prefs.getPref(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS))); + cboNoActionsScope.setDropDownChangeHandler(e -> persistScope(ctrl, FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, noActionsScopes, cboNoActionsScope.getSelectedIndex())); + + chkSuppressOwnTurn = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressOnOwnTurn"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_ON_OWN_TURN))); + chkSuppressAfterYield = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSuppressAfterYield"), prefs.getPrefBoolean(FPref.YIELD_SUPPRESS_AFTER_END))); + chkSuppressOwnTurn.setCommand(e -> persistBool(ctrl, FPref.YIELD_SUPPRESS_ON_OWN_TURN, chkSuppressOwnTurn.isSelected())); + chkSuppressAfterYield.setCommand(e -> persistBool(ctrl, FPref.YIELD_SUPPRESS_AFTER_END, chkSuppressAfterYield.isSelected())); + + hdrSpeed = scroller.add(headerLabel("lblSpeedSettings")); + lblBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetLabel")) + .align(Align.left) + .build()); + descBudget = scroller.add(new FLabel.Builder() + .text(Forge.getLocalizer().getMessage("lblAutoPassBudgetDesc")) + .font(DESC_FONT) + .textColor(DESC_COLOR) + .align(Align.left) + .build()); + int currentBudget = prefs.getPrefInt(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + txtBudgetMs = scroller.add(new FTextField(currentBudget > 0 ? String.valueOf(currentBudget) : "")); + txtBudgetMs.setGhostText(Forge.getLocalizer().getMessage("lblDynamic")); + txtBudgetMs.setIsNumeric(true); + + chkSkipPhaseDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipPhaseDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_PHASE_DELAY))); + chkSkipResolveDelay = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("lblSkipResolveDelay"), prefs.getPrefBoolean(FPref.YIELD_SKIP_RESOLVE_DELAY))); + chkSkipPhaseDelay.setCommand(e -> persistBool(ctrl, FPref.YIELD_SKIP_PHASE_DELAY, chkSkipPhaseDelay.isSelected())); + chkSkipResolveDelay.setCommand(e -> persistBool(ctrl, FPref.YIELD_SKIP_RESOLVE_DELAY, chkSkipResolveDelay.isSelected())); + + FLabel[] bodyLabels = { + chkInterruptAttackers, chkInterruptTargeting, chkInterruptMassRemoval, + chkInterruptOpponentSpell, chkInterruptTriggers, chkInterruptReveal, + chkAutoPassRespectsInterrupts, + lblStackScope, lblNoActionsScope, + chkSuppressOwnTurn, chkSuppressAfterYield, + lblBudget, + chkSkipPhaseDelay, chkSkipResolveDelay + }; + for (FLabel l : bodyLabels) l.setFont(BODY_FONT); + cboStackScope.setFont(BODY_FONT); + cboNoActionsScope.setFont(BODY_FONT); + txtBudgetMs.setFont(BODY_FONT); + + initButton(0, Forge.getLocalizer().getMessage("lblOK"), e -> { + String txt = txtBudgetMs.getText().trim(); + if (txt.isEmpty()) { + txt = "0"; + } + try { + int v = Integer.parseInt(txt); + if (v < 0) v = 0; + persistString(ctrl, FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, String.valueOf(v)); + } catch (NumberFormatException nfe) { + FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblInvalidBudget")); + return; + } + hide(); + }); + } + + private static FLabel headerLabel(String localizerKey) { + return new FLabel.ButtonBuilder() + .text(Forge.getLocalizer().getMessage(localizerKey)) + .font(FSkinFont.get(14)) + .align(Align.center) + .build(); + } + + private static FLabel descriptionLabel(String localizerKey) { + return new FLabel.Builder() + .text(Forge.getLocalizer().getMessage(localizerKey)) + .font(DESC_FONT) + .textColor(DESC_COLOR) + .align(Align.left) + .build(); + } + + private FScrollPane.ScrollBounds layoutScrollerContent(float visibleWidth) { + float padding = FOptionPane.PADDING; + float rowGap = Utils.scale(6); + float sectionGap = Utils.scale(14); + + float x = padding; + float w = visibleWidth - 2 * padding; + float y = padding; + + TextBounds rowBounds = chkInterruptAttackers.getAutoSizeBounds(); + float rowH = Math.max(rowBounds.height, Utils.scale(28)); + float headerH = Math.round(Utils.AVG_FINGER_HEIGHT * 0.6f); + + hdrInterrupts.setBounds(x, y, w, headerH); + y += headerH; + float descH = DESC_FONT.getCapHeight() * 2.2f; + descInterrupts.setBounds(x, y, w, descH); + y += descH + Utils.scale(2); + FCheckBox[] interrupts = { + chkInterruptAttackers, chkInterruptTargeting, chkInterruptMassRemoval, + chkInterruptOpponentSpell, chkInterruptTriggers, chkInterruptReveal, + chkAutoPassRespectsInterrupts + }; + for (FCheckBox cb : interrupts) { + cb.setBounds(x, y, w, rowH); + y += rowH + rowGap; + } + y += sectionGap - rowGap; + + hdrSuggestions.setBounds(x, y, w, headerH); + y += headerH; + descSuggestions.setBounds(x, y, w, descH); + y += descH + Utils.scale(2); + + float dropdownW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float scopeLabelW = w - dropdownW - padding; + float scopeRowH = Math.max(rowH, FTextField.getDefaultHeight()); + + lblStackScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboStackScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + lblNoActionsScope.setBounds(x, y, scopeLabelW, scopeRowH); + cboNoActionsScope.setBounds(x + w - dropdownW, y, dropdownW, scopeRowH); + y += scopeRowH + rowGap; + + chkSuppressOwnTurn.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSuppressAfterYield.setBounds(x, y, w, rowH); + y += rowH + sectionGap; + + hdrSpeed.setBounds(x, y, w, headerH); + y += headerH; + + float fieldH = FTextField.getDefaultHeight(); + float fieldW = Math.min(visibleWidth * 0.45f, Utils.scale(160)); + float lblW = w - fieldW - padding; + lblBudget.setBounds(x, y, lblW, fieldH); + txtBudgetMs.setBounds(x + w - fieldW, y, fieldW, fieldH); + y += fieldH + Utils.scale(2); + descBudget.setBounds(x, y, w, DESC_FONT.getCapHeight() * 2.2f); + y += DESC_FONT.getCapHeight() * 2.2f + rowGap; + + chkSkipPhaseDelay.setBounds(x, y, w, rowH); + y += rowH + rowGap; + chkSkipResolveDelay.setBounds(x, y, w, rowH); + y += rowH + padding; + + return new FScrollPane.ScrollBounds(visibleWidth, y); + } + + private static int scopeIndex(DeclineScope[] options, String prefValue) { + DeclineScope current = DeclineScope.fromPref(prefValue); + for (int i = 0; i < options.length; i++) { + if (options[i] == current) return i; + } + return 0; + } + + private static void persistScope(IGameController ctrl, FPref pref, DeclineScope[] options, int index) { + if (index < 0 || index >= options.length) return; + String value = options[index].name(); + persistString(ctrl, pref, value); + } + private static void persistBool(IGameController ctrl, FPref pref, boolean value) { + persistString(ctrl, pref, String.valueOf(value)); + } + private static void persistString(IGameController ctrl, FPref pref, String value) { + FModel.getPreferences().setPref(pref, value); + FModel.getPreferences().save(); + if (ctrl != null) ctrl.setYieldPref(pref, value); + } + + @Override + protected float layoutAndGetHeight(float width, float maxHeight) { + scroller.setBounds(0, 0, width, maxHeight); + scroller.scrollToTop(); + return maxHeight; + } +} diff --git a/forge-gui/res/defaults/match.xml b/forge-gui/res/defaults/match.xml index 839733ea2a8..314c77f3828 100644 --- a/forge-gui/res/defaults/match.xml +++ b/forge-gui/res/defaults/match.xml @@ -1,16 +1,18 @@ - + REPORT_STACK REPORT_COMBAT REPORT_LOG REPORT_DEPENDENCIES - + + BUTTON_DOCK + + REPORT_MESSAGE DEV_MODE CARD_ANTES - BUTTON_DOCK FIELD_1 diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index 4cd3cb386c3..d05288f7473 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -451,7 +451,7 @@ lblSHORTCUT_SHOWCOMBAT=Duell: Zeige Kampffenster lblSHORTCUT_SHOWCONSOLE=Duell: Zeige Konsolenfenster lblSHORTCUT_SHOWDEV=Duell: Zeige Entwicklerfenster lblSHORTCUT_CONCEDE=Duell: Spiel aufgeben -lblSHORTCUT_ENDTURN=Duell: Priorität bis Zugende oder nächstem Ereignis abgeben +lblSHORTCUT_ENDTURN=Duell: Priorität bis Zugende oder Unterbrechung abgeben lblSHORTCUT_ALPHASTRIKE=Duell: Alpha Strike (Mit allem angreifen) lblSHORTCUT_SHOWTARGETING=Duell: Zielpfeile umschalten lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Duel: Auto-Bestätigen von Fähigkeiten auf dem Stapel (immer Ja) diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 3cf177c7aeb..0e7d9ec275c 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -16,7 +16,6 @@ lblAreYouSureYouWishExitForge=Are you sure you wish to exit Forge? lblOneOrMoreGamesActive=One or more games are currently active lblerrLoadingLayoutFile=Your %s layout file could not be read. It will be deleted after you press OK.\nThe game will proceed with default layout. lblLoadingQuest=Loading quest... - #CardType.java lblKindred=Kindred lblLand=Land @@ -41,7 +40,6 @@ lblLegendary=Legendary lblSnow=Snow lblOngoing=Ongoing lblWorld=World - #FScreen.java #translate lblHomeWithSpaces,lblDeckEditorWithSpaces need keep spaces in text lblHomeWithSpaces=Home @@ -517,7 +515,7 @@ lblSHORTCUT_SHOWCONSOLE=Match: show console panel lblSHORTCUT_SHOWDEV=Match: show dev panel lblSHORTCUT_UNDO=Match: undo last action lblSHORTCUT_CONCEDE=Match: concede game -lblSHORTCUT_ENDTURN=Match: pass priority until EOT or next stack event +lblSHORTCUT_ENDTURN=Match: pass priority until end of turn or interrupt lblSHORTCUT_ALPHASTRIKE=Match: Alpha Strike (attack with all available) lblSHORTCUT_SHOWTARGETING=Match: toggle targeting visual overlay lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Match: auto-yield ability on stack (Always Yes) @@ -684,7 +682,6 @@ lblGreen=Green lblRed=Red lblWhite=White lblColorless=Colorless -lblSnow=Snow lblIncludeArtifacts=Include Artifacts lblBalanced=Balanced lblTrueRandom=True Random @@ -826,7 +823,6 @@ lblCredits=Credits lblLife=Life lblWins=Wins lblLosses=Losses -lblWorld=World lblNone=None lblnextChallengeInWins0=Your exploits have been noticed. An opponent has challenged you. lblnextChallengeInWins1=A new challenge will be available after 1 more win. @@ -1626,7 +1622,45 @@ lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. lblYieldingUntilPhaseFmt=Yielding until {0}''s {1}.\nYou may cancel this yield to take an action. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. -lblYieldToEntireStack=Yield to entire stack +lblYieldToStack=Yield to stack +lblYieldToEntireStack=Resolve entire stack +lblYieldSettings=Yield Settings +lblEnableAutoPass=Enable auto-pass +lblYieldBtnAutoPass=Auto-Pass: OFF +lblYieldBtnAutoPassOn=Auto-Pass: ON +lblYieldBtnAutoPassTooltip=Auto-pass: Automatically pass priority when you have no spells to cast, abilities to activate, lands to play, or attackers to declare. +lblInterruptSettings=Yield Interrupt Settings +lblInterruptSettingsDesc=Cancel an active yield and return priority to you when these game events occur. +lblInterruptOnAttackers=When attackers declared against you +lblInterruptOnTargeting=When targeted by spell or ability +lblInterruptOnOpponentSpell=When opponent casts a spell or activates an ability +lblInterruptOnReveal=When cards revealed or choices made +lblInterruptOnMassRemoval=When mass removal spell cast +lblInterruptOnTriggers=When triggered abilities on stack +lblAutoPassRespectsInterrupts=Auto-pass respects interrupts +lblAutoPassBudgetLabel=Auto-pass calculation timeout (ms) +lblAutoPassBudgetDesc=Dynamic = 50ms × playable cards (50-1500ms) +lblDynamic=Dynamic +lblInvalidBudget=Budget must be a non-negative integer. +lblAutomaticSuggestions=Automatic Yield Suggestions +lblAutomaticSuggestionsDesc=Prompt you to start a yield when no useful actions are available. +lblSuggestStackYield=When can''t respond to stack +lblSuggestNoActions=When no actions available +lblSuppressAfterYield=Suppress immediately after yield ends +lblSuppressOnOwnTurn=Suppress on own turn +lblAccept=Accept +lblDecline=Decline +lblDeclScopeStack=Once per stack +lblDeclScopeTurn=Once per turn +lblCannotRespondToStackYieldPrompt=You cannot respond to the stack. Yield until stack clears? +lblNoActionsAvailableYieldPrompt=You have no available actions. Yield until your turn? +lblYieldSuggestionDeclineHint=(Declining disables this prompt until next turn.) +lblYieldSuggestionDeclineHintStack=(Declining disables this prompt until the stack clears.) +lblSpeedSettings=Speed Settings +lblSkipPhaseDelay=Skip delay between phases +lblSkipResolveDelay=Skip delay when stack resolves +lblSHORTCUT_YIELD_OPTIONS=Yield: open Yield Options menu +lblSHORTCUT_YIELD_AUTO_PASS=Yield: toggle Auto-Pass / cancel active yields lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: @@ -2531,7 +2565,6 @@ lblZoomOrDetails=Zoom/Details #AdvancedSearch.java lblRulesText=Rules Text lblKeywords=Keywords -lblPlane=Plane lblRegion=Region lblColorCount=Color Count lblSupertype=Supertype @@ -2662,7 +2695,6 @@ lblReceivedAetherShardsForDuplicateCards=Received Aether Shards for Duplicate Ca lblReceivedBonusPlaneswalkEmblems=Received Bonus Planeswalk Emblems lblStartingBattle=Starting battle... lblChaosApproaching=Chaos approaching... -lblBattle=Battle #ConquestDeckEditor.java lblConquestCommander=Conquest Commander #ConquestPlaneSelector.java diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 505bb70742c..f87ec1c5a51 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -428,7 +428,7 @@ lblSHORTCUT_SHOWCOMBAT=Partida: mostrar panel de combate lblSHORTCUT_SHOWCONSOLE=Partida: mostrar el panel de la consola lblSHORTCUT_SHOWDEV=Partida: mostrar panel de desarrollo lblSHORTCUT_CONCEDE=Partida: conceder juego -lblSHORTCUT_ENDTURN=Partida: pasa la prioridad hasta fin del turno o siguiente evento de pila +lblSHORTCUT_ENDTURN=Partida: pasa la prioridad hasta fin del turno o interrupción lblSHORTCUT_ALPHASTRIKE=Partida: Alpha Strike (ataque con todos los disponibles) lblSHORTCUT_SHOWTARGETING=Partida: alternar la orientación visual de superposición lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Partida: ceder automáticamente en cada habilidad de la pila (Siempre Sí) diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index e6d86b95709..d4f1830d087 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -426,7 +426,7 @@ lblSHORTCUT_SHOWCOMBAT=Match : afficher le panneau de combat lblSHORTCUT_SHOWCONSOLE=Correspondance : afficher le panneau de la console lblSHORTCUT_SHOWDEV=Correspondance : afficher le panneau de développement lblSHORTCUT_CONCEDE=Match : concéder le jeu -lblSHORTCUT_ENDTURN=Match : passer la priorité jusqu'à l'EOT ou le prochain événement de pile +lblSHORTCUT_ENDTURN=Match : passer la priorité jusqu'à la fin du tour ou interruption lblSHORTCUT_ALPHASTRIKE=Match : Frappe alpha (attaque avec tous disponibles) lblSHORTCUT_SHOWTARGETING=Correspondance : basculer la superposition visuelle du ciblage lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Match : capacité de rendement automatique sur la pile (toujours oui) diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index fae525ace19..1220da2eb57 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -425,7 +425,7 @@ lblSHORTCUT_SHOWCOMBAT=Incontro: mostra il pannello di combattimento lblSHORTCUT_SHOWCONSOLE=Incontro: mostra il pannello della console lblSHORTCUT_SHOWDEV=Incontro: mostra il pannello per gli sviluppatori lblSHORTCUT_CONCEDE=Incontro: concedi la partita -lblSHORTCUT_ENDTURN=Incontro: passa la priorità fino alla fine del turno o al prossimo evento della pila +lblSHORTCUT_ENDTURN=Incontro: passa la priorità fino alla fine del turno o interruzione lblSHORTCUT_ALPHASTRIKE=Incontro: Alpha Strike lblSHORTCUT_SHOWTARGETING=Incontro: attiva / disattiva gli indicatori dei bersagli lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Incontro: consenso automatico in pila: sempre Sì diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index 92510c77c30..911a9acba2f 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -426,7 +426,7 @@ lblSHORTCUT_SHOWCOMBAT=戦闘パネルを表示 lblSHORTCUT_SHOWCONSOLE=コンソールパネルを表示 lblSHORTCUT_SHOWDEV=開発者パネルを表示 lblSHORTCUT_CONCEDE=ゲームを投了する -lblSHORTCUT_ENDTURN=ターンエンドまたは次のスタックイベントまで優先権を渡す +lblSHORTCUT_ENDTURN=ターンエンドまたは割り込みまで優先権を渡す lblSHORTCUT_ALPHASTRIKE=全員突撃(すべての攻撃可能なクリーチャーを指定) lblSHORTCUT_SHOWTARGETING=ビジュアルオーバーレイのターゲットを切り替える lblSHORTCUT_AUTOYIELD_ALWAYS_YES=スタック解決時、優先権の自動放棄(常にはい) diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 26c783a840f..1c39f567e56 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -460,7 +460,7 @@ lblSHORTCUT_SHOWCOMBAT=전투 패널 표시 lblSHORTCUT_SHOWCONSOLE=콘솔 패널 표시 lblSHORTCUT_SHOWDEV=개발자 패널 표시 lblSHORTCUT_CONCEDE=게임을 포기합니다 -lblSHORTCUT_ENDTURN=턴 종료 또는 다음 스택 이벤트까지 우선권 전달 +lblSHORTCUT_ENDTURN=턴 종료 또는 인터럽트까지 우선권 전달 lblSHORTCUT_ALPHASTRIKE=전원 돌격 (모든 공격 가능한 크리처 지정) lblSHORTCUT_SHOWTARGETING=시각적 오버레이 타깃 전환 lblSHORTCUT_AUTOYIELD_ALWAYS_YES=스택 해결 시, 우선권 자동 포기 (항상 예) diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 7b8f451c5b4..bfcd46d075a 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -438,7 +438,7 @@ lblSHORTCUT_SHOWCOMBAT=Partida\: mostrar painel de combate lblSHORTCUT_SHOWCONSOLE=Partida\: mostrar painel de console lblSHORTCUT_SHOWDEV=Partida\: mostrar painel dev lblSHORTCUT_CONCEDE=Partida\: conceda o jogo -lblSHORTCUT_ENDTURN=Partida\: passar prioridade até Final Turno ou próximo evento da pilha +lblSHORTCUT_ENDTURN=Partida\: passar prioridade até Final Turno ou interrupção lblSHORTCUT_ALPHASTRIKE=Partida\: Ataque Alfa (ataque com todos disponíveis) lblSHORTCUT_SHOWTARGETING=Partida\: mudar a indicação visual do alvo lblSHORTCUT_AUTOYIELD_ALWAYS_YES=Partida\: resolver automático a pilha (Sempre Sim) diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 6ebaf5fccf9..8042582e657 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -428,7 +428,7 @@ lblSHORTCUT_SHOWCOMBAT=匹配:显示战斗面板 lblSHORTCUT_SHOWCONSOLE=匹配:显示控制台面板 lblSHORTCUT_SHOWDEV=匹配:显示开发人员模式面板 lblSHORTCUT_CONCEDE=匹配:认输 -lblSHORTCUT_ENDTURN=匹配:让过优先权直到回合结束或者下一个堆叠事件 +lblSHORTCUT_ENDTURN=匹配:让过优先权直到回合结束或打断 lblSHORTCUT_ALPHASTRIKE=匹配:先攻 lblSHORTCUT_SHOWTARGETING=匹配:切换视觉叠加层 lblSHORTCUT_AUTOYIELD_ALWAYS_YES=匹配:自动让过堆叠中的异能(所有都选是) 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 c12332d9eae..2f53e6125b3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -493,7 +493,7 @@ private String currentYieldMessage() { YieldController yielding = null; for (IGameController c : gameControllers.values()) { YieldController yc = c.getYieldController(); - if (yc != null && yc.shouldAutoYield()) { + if (yc.shouldAutoYield()) { yielding = yc; break; } @@ -622,6 +622,7 @@ public void applyYieldUpdate(YieldUpdate update) { PlayerView pv; if (update instanceof YieldUpdate.ClearMarker u) pv = u.player(); else if (update instanceof YieldUpdate.StackYield u) pv = u.player(); + else if (update instanceof YieldUpdate.SetAutoPassUntilEndOfTurn u) pv = u.player(); else return; IGameController c = getGameController(pv); if (c != null) c.applyYieldUpdate(update); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java new file mode 100644 index 00000000000..3f60fab1857 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/DeclineScope.java @@ -0,0 +1,24 @@ +package forge.gamemodes.match; + +/** How long a smart-suggestion decline suppresses the suggestion. Persisted via {@link Enum#name()}. */ +public enum DeclineScope { + NEVER("lblNever"), + ALWAYS("lblAlways"), + STACK("lblDeclScopeStack"), + TURN("lblDeclScopeTurn"); + + private final String labelKey; + + DeclineScope(String labelKey) { + this.labelKey = labelKey; + } + + /** Localizer key for the dropdown label. */ + public String labelKey() { return labelKey; } + + /** Parse stored FPref value. Unknown / null → {@link #NEVER}. */ + public static DeclineScope fromPref(String s) { + if (s == null || s.isEmpty()) return NEVER; + try { return valueOf(s); } catch (IllegalArgumentException ignored) { return NEVER; } + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 1f1b198a92b..64867031ac8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -389,10 +389,10 @@ public void endCurrentGame() { humanController.getGui().setGameSpeed(PlaybackSpeed.NORMAL); humanController.getYieldController().clearAutoYields(); - if (humanCount > 0) //conceded - humanController.getGui().afterGameEnd(); - else if (!GuiBase.getInterface().isLibgdxPort()||!isMatchOver) + //conceded + if (humanCount > 0 || !GuiBase.getInterface().isLibgdxPort() || !isMatchOver) { humanController.getGui().afterGameEnd(); + } humanController.getGui().updateDayTime(null); } humanControllers.clear(); @@ -425,10 +425,6 @@ private final class MatchUiEventVisitor extends IGameEventVisitor.Base imp public Void visit(final UiEventBlockerAssigned event) { for (final PlayerControllerHuman humanController : humanControllers) { humanController.getGui().updateSingleCard(event.blocker()); - final PlayerView p = humanController.getPlayer().getView(); - if (event.attackerBeingBlocked() != null && event.attackerBeingBlocked().getController().equals(p)) { - humanController.autoPassCancel(); - } } return null; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java b/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java new file mode 100644 index 00000000000..e714a7a341f --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/SuggestionType.java @@ -0,0 +1,29 @@ +package forge.gamemodes.match; + +import forge.localinstance.properties.ForgePreferences.FPref; + +import java.util.EnumSet; +import java.util.Set; + +import static forge.gamemodes.match.DeclineScope.ALWAYS; +import static forge.gamemodes.match.DeclineScope.NEVER; +import static forge.gamemodes.match.DeclineScope.STACK; +import static forge.gamemodes.match.DeclineScope.TURN; + +/** A user-facing yield suggestion. Owns the metadata that constrains its UX: + * which decline scopes apply, and which FPref persists the chosen scope. */ +public enum SuggestionType { + STACK_YIELD(EnumSet.of(NEVER, ALWAYS, STACK, TURN), FPref.YIELD_DECLINE_SCOPE_STACK_YIELD), + NO_ACTIONS (EnumSet.of(NEVER, ALWAYS, TURN), FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + + private final Set allowedScopes; + private final FPref scopePref; + + SuggestionType(Set allowedScopes, FPref scopePref) { + this.allowedScopes = allowedScopes; + this.scopePref = scopePref; + } + + public Set allowedScopes() { return allowedScopes; } + public FPref scopePref() { return scopePref; } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 4ae104fd261..3f29e89e175 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -1,16 +1,26 @@ package forge.gamemodes.match; import forge.game.GameView; +import forge.game.ability.ApiType; +import forge.game.card.CardView; +import forge.game.combat.CombatView; import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.game.spellability.SpellAbilityStackInstance; +import forge.game.spellability.StackItemView; +import forge.gui.interfaces.IGuiGame; +import forge.interfaces.IGameController; import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.AutoYieldStore; import forge.player.LobbyPlayerHuman; import forge.player.PersistentAutoDecisionStore; import forge.player.PlayerControllerHuman; +import forge.util.collect.FCollectionView; +import java.util.EnumMap; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -31,10 +41,31 @@ */ public class YieldController { + /** Yield FPrefs synced per-PCH; enumerated here so the client snapshot includes every value, not just touched overrides. + * Stored String-typed (see {@link forge.localinstance.properties.PreferencesStore}); consumers parse via {@link #getBoolPref}/{@link #getStringPref} according to the pref's expected type. */ + private static final EnumSet SYNCED_PREFS = EnumSet.of( + FPref.YIELD_INTERRUPT_ON_ATTACKERS, + FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL, + FPref.YIELD_INTERRUPT_ON_TARGETING, + FPref.YIELD_INTERRUPT_ON_TRIGGERS, + FPref.YIELD_INTERRUPT_ON_REVEAL, + FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL, + FPref.YIELD_AUTO_PASS_NO_ACTIONS, + FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS, + FPref.YIELD_SKIP_PHASE_DELAY, + FPref.YIELD_SKIP_RESOLVE_DELAY, + FPref.YIELD_SUPPRESS_ON_OWN_TURN, + FPref.YIELD_SUPPRESS_AFTER_END, + FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, + FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + private final PlayerControllerHuman owner; private boolean autoPassUntilEOT; private boolean autoPassUntilStackEmpty; + /** When true, an active stack-yield is cleared by interrupt classifiers; when false, only stack-empty turns it off. */ + private boolean stackYieldRespectsInterrupts; private YieldMarker autoPassUntilMarker; /** Priority has passed through any non-target phase since marker activation. */ @@ -45,16 +76,33 @@ public class YieldController { private final AutoYieldStore localStore = new AutoYieldStore(); private final Map> skipPhases = new HashMap<>(); + /** Populated only on the host's proxy of a remote player (via {@link #applyClientSeed} and {@link YieldUpdate.SetYieldPref} envelopes); local controllers always defer to FModel. Override wins, FModel is fallback. */ + private final EnumMap prefOverrides = new EnumMap<>(FPref.class); + public YieldController(PlayerControllerHuman owner) { this.owner = owner; } + public synchronized String getStringPref(FPref pref) { + String override = prefOverrides.get(pref); + return override != null ? override : FModel.getPreferences().getPref(pref); + } + public boolean getBoolPref(FPref pref) { + return Boolean.parseBoolean(getStringPref(pref)); + } + public synchronized void setPref(FPref pref, String value) { + prefOverrides.put(pref, value); + } + + public DeclineScope getDeclineScope(FPref pref) { + return DeclineScope.fromPref(getStringPref(pref)); + } + public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { EnumSet set = skipPhases.get(turnPlayer); return set != null && set.contains(phase); } public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { - if (turnPlayer == null || phase == null) return; EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); if (skip) set.add(phase); else set.remove(phase); @@ -70,21 +118,28 @@ public YieldMarker getAutoPassUntilMarker() { return autoPassUntilMarker; } - public void setAutoPassUntilStackEmpty(boolean active) { - if (active) autoPassUntilEOT = false; + // All mutators synchronized — fields touched from EDT, Netty, game thread. + // Activating any yield type clears the others — only one yield type may be active at a time (APINA is orthogonal). + public synchronized void setAutoPassUntilStackEmpty(boolean active, boolean respectsInterrupts) { + if (active) { + autoPassUntilEOT = false; + clearMarker(); + } this.autoPassUntilStackEmpty = active; + this.stackYieldRespectsInterrupts = active && respectsInterrupts; } - public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { + public synchronized void setAutoPassUntilEndOfTurn(boolean active) { + if (active) { + autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; + clearMarker(); + } this.autoPassUntilEOT = active; } - - // setMarker/clearMarker are mutated from EDT (right-click), Netty (wire receive), and game thread. public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; - if (phaseOwner == null || phase == null) { - clearMarker(); - return; - } + autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; autoPassUntilMarker = new YieldMarker(phaseOwner, phase); // Activating at-or-past target on the owner's current turn must wait for next turn's // occurrence; otherwise pastTarget would fire and clear the marker on the same turn. @@ -111,6 +166,7 @@ public boolean shouldAutoYield() { if (autoPassUntilStackEmpty) { if (gv != null && gv.peekStack() != null) return true; autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; } if (autoPassUntilMarker != null && gv != null) { PlayerView turnPlayer = gv.getPlayerTurn(); @@ -190,11 +246,6 @@ private static AutoYieldStore.Tier activeTier() { return AutoYieldStore.Tier.MATCH; } - /** Cache-mode write of a wire-received update. Storage key is already at the right shape. */ - public void applyAutoYieldFromWire(String storageKey, boolean active) { - activeStore().setYield(AutoYieldStore.Tier.GAME, storageKey, active); - } - public Iterable getAutoYields() { AutoYieldStore store = activeStore(); if (!tierAware()) return store.getYields(AutoYieldStore.Tier.GAME); @@ -207,8 +258,7 @@ public void clearAutoYields() { localStore.clear(); return; } - boolean matchOver = owner.getGame() == null || owner.getGame().getView().isMatchOver(); - activeStore().onGameEnd(matchOver); + activeStore().onGameEnd(owner.getGame() == null || owner.getGame().getView().isMatchOver()); } public boolean getDisableAutoYields() { @@ -247,11 +297,6 @@ public String setTriggerDecision(String key, AutoYieldStore.TriggerDecision deci return storageKey; } - /** Cache-mode write of a wire-received trigger decision. Storage key is already at the right shape. */ - public void applyTriggerDecisionFromWire(String storageKey, AutoYieldStore.TriggerDecision decision) { - activeStore().setTriggerDecision(AutoYieldStore.Tier.GAME, storageKey, decision); - } - public Iterable> getAutoTriggers() { if (!tierAware()) return activeStore().getAutoTriggers(AutoYieldStore.Tier.GAME); if (activeModeIsInstall()) return PersistentAutoDecisionStore.get().getAutoTriggers(); @@ -279,11 +324,14 @@ public YieldStateSnapshot buildClientSnapshot(Map else cardTriggers.put(e.getKey(), e.getValue()); } + EnumMap prefs = new EnumMap<>(FPref.class); + for (FPref pref : SYNCED_PREFS) prefs.put(pref, FModel.getPreferences().getPref(pref)); + return new YieldStateSnapshot( cardYields, abilityYields, cardTriggers, abilityTriggers, getDisableAutoYields(), getDisableAutoTriggers(), - skipPhases); + skipPhases, prefs); } /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ @@ -301,5 +349,270 @@ public void applyClientSeed(YieldStateSnapshot snap) { localStore.setTriggerDecisionsDisabled(snap.autoTriggersDisabled()); skipPhases.clear(); skipPhases.putAll(snap.skipPhases()); + prefOverrides.clear(); + prefOverrides.putAll(snap.prefOverrides()); } + + /** + * Apply a wire envelope variant to local state. Returns true if a yield was + * activated (caller may want to refresh the prompt UI). Per-method locking is + * provided by the delegated setters. Unhandled variants no-op — wire routing + * is responsible for ensuring only valid variants reach each side. + */ + public boolean apply(YieldUpdate update) { + if (update instanceof YieldUpdate.SetMarker u) { + setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); + return true; + } else if (update instanceof YieldUpdate.ClearMarker) { + clearMarker(); + } else if (update instanceof YieldUpdate.StackYield u) { + setAutoPassUntilStackEmpty(u.active(), u.respectsInterrupts()); + return u.active(); + } else if (update instanceof YieldUpdate.SetAutoPassUntilEndOfTurn u) { + setAutoPassUntilEndOfTurn(u.active()); + return u.active(); + } else if (update instanceof YieldUpdate.CardAutoYield u) { + activeStore().setYield(AutoYieldStore.Tier.GAME, u.cardKey(), u.active()); + } else if (update instanceof YieldUpdate.TriggerDecision u) { + activeStore().setTriggerDecision(AutoYieldStore.Tier.GAME, u.storageKey(), u.decision()); + } else if (update instanceof YieldUpdate.SetDisableYields u) { + setDisableAutoYields(u.disabled()); + } else if (update instanceof YieldUpdate.SetDisableTriggers u) { + setDisableAutoTriggers(u.disabled()); + } else if (update instanceof YieldUpdate.SkipPhase u) { + setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); + } else if (update instanceof YieldUpdate.SetYieldPref u) { + setPref(u.pref(), u.value()); + } else if (update instanceof YieldUpdate.SeedFromClient u) { + applyClientSeed(u.snapshot()); + } + return false; + } + + public boolean isYieldActive() { + return autoPassUntilEOT || autoPassUntilStackEmpty || autoPassUntilMarker != null; + } + + /** EOT, marker, and interruptible stack-yields back off via {@link #applyInterrupt()}. The non-interruptible stack-yield is fire-and-forget — only stack-empty turns it off. */ + public boolean isInterruptibleYieldActive() { + return autoPassUntilEOT + || autoPassUntilMarker != null + || (autoPassUntilStackEmpty && stackYieldRespectsInterrupts); + } + + public synchronized void clearActiveYieldAndDispatch() { + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + IGuiGame gui = owner.getGui(); + boolean anyCleared = false; + if (autoPassUntilMarker != null) { + clearMarker(); + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.ClearMarker(local)); + anyCleared = true; + } + if (autoPassUntilStackEmpty) { + autoPassUntilStackEmpty = false; + stackYieldRespectsInterrupts = false; + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.StackYield(local, false, false)); + anyCleared = true; + } + if (autoPassUntilEOT) { + autoPassUntilEOT = false; + if (gui != null) gui.applyYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, false)); + anyCleared = true; + } + if (anyCleared && gui != null) gui.updateAutoPassPrompt(); + } + + /** Toggle APINA: flip pref, persist, push to controller. Returns new value. */ + public static boolean toggleAutoPassNoActions(IGameController ctrl) { + if (ctrl == null) return false; + ForgePreferences prefs = FModel.getPreferences(); + boolean newVal = !prefs.getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + prefs.setPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, newVal); + prefs.save(); + ctrl.setYieldPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS, String.valueOf(newVal)); + return newVal; + } + + /** + * Single unified yield-key action. If any yield is currently active (transient + * yield state or APINA on), clears everything. Otherwise turns APINA on. So one + * key acts as both "stop any yielding" and "start auto-passing" depending on state. + */ + public static void toggleAutoPassOrStopAll(IGameController ctrl) { + if (ctrl == null) return; + YieldController yc = ctrl.getYieldController(); + boolean apinaOn = FModel.getPreferences().getPrefBoolean(FPref.YIELD_AUTO_PASS_NO_ACTIONS); + if (yc.isYieldActive() || apinaOn) { + yc.clearActiveYieldAndDispatch(); + if (apinaOn) toggleAutoPassNoActions(ctrl); + } else { + toggleAutoPassNoActions(ctrl); + } + } + + /** Activate auto-pass-until-end-of-turn for {@code local} via the unified envelope path. */ + public static void endTurn(IGameController ctrl, PlayerView local) { + if (ctrl == null || local == null) return; + ctrl.sendYieldUpdate(new YieldUpdate.SetAutoPassUntilEndOfTurn(local, true)); + } + + /** Eager check at REVEAL/notifyOfValue call sites — no GameEventReveal exists. */ + public void maybeInterruptOnReveal() { + if (!getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) return; + applyInterrupt(); + } + + public void onSpellAbilityCast(SpellAbilityStackInstance si) { + if (!shouldEvaluateInterrupts()) return; + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + boolean isOpponent = !si.getActivatingPlayer().getView().equals(local); + + if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { + applyInterrupt(); + return; + } + StackItemView siv = StackItemView.get(si); + if (siv != null && getBoolPref(FPref.YIELD_INTERRUPT_ON_TARGETING) + && targetsPlayerOrPermanents(siv, local)) { + applyInterrupt(); + return; + } + if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_MASS_REMOVAL) + && isMassRemoval(si)) { + applyInterrupt(); + return; + } + if (si.isTrigger() && getBoolPref(FPref.YIELD_INTERRUPT_ON_TRIGGERS)) { + applyInterrupt(); + } + } + + public void onAttackersDeclared(CombatView combat) { + if (!shouldEvaluateInterrupts()) return; + PlayerView local = owner != null ? owner.getLocalPlayerView() : null; + if (local == null) return; + if (getBoolPref(FPref.YIELD_INTERRUPT_ON_ATTACKERS) && isBeingAttacked(combat, local)) { + applyInterrupt(); + } + } + + private boolean shouldEvaluateInterrupts() { + if (isInterruptibleYieldActive()) return true; + return getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS); + } + + /** Apply an interrupt: clear any interruptible yield and pause APINA for one prompt. Either, both, or neither may apply. */ + public synchronized void applyInterrupt() { + if (isInterruptibleYieldActive()) clearActiveYieldAndDispatch(); + if (getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS)) { + autoPassInterrupted = true; + } + } + + private final EnumMap declinedSuggestionTurn = new EnumMap<>(SuggestionType.class); + private boolean lastSeenStackNonEmpty = false; + private boolean wasAutoPassingLastTick = false; + private boolean yieldJustEndedFlag = false; + private boolean autoPassInterrupted = false; + + public synchronized void onPriorityReceived(boolean stackNonEmpty) { + if (lastSeenStackNonEmpty && !stackNonEmpty && + getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) == DeclineScope.STACK) { + declinedSuggestionTurn.remove(SuggestionType.STACK_YIELD); + } + lastSeenStackNonEmpty = stackNonEmpty; + } + + public synchronized void declineSuggestion(SuggestionType type) { + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv == null) return; + declinedSuggestionTurn.put(type, gv.getTurn()); + } + + /** + * NEVER disables the suggestion entirely; ALWAYS never suppresses; + * STACK/TURN suppress if declined this turn (STACK also self-clears on stack-empty + * transition via {@link #onPriorityReceived}). + */ + public synchronized boolean isSuggestionDeclined(SuggestionType type) { + DeclineScope scope = getDeclineScope(type.scopePref()); + if (scope == DeclineScope.NEVER) return true; + if (scope == DeclineScope.ALWAYS) return false; + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv == null) return false; + Integer turnDeclined = declinedSuggestionTurn.get(type); + return turnDeclined != null && turnDeclined == gv.getTurn(); + } + + public synchronized void noteMayAutoPassResult(boolean nowMayAutoPass) { + if (wasAutoPassingLastTick && !nowMayAutoPass) yieldJustEndedFlag = true; + wasAutoPassingLastTick = nowMayAutoPass; + if (!nowMayAutoPass) autoPassInterrupted = false; + } + + /** Self-clearing read of the mayAutoPass true→false edge. */ + public synchronized boolean didYieldJustEnd() { + boolean f = yieldJustEndedFlag; + yieldJustEndedFlag = false; + return f; + } + + /** Per-tick predicate (distinct from one-shot yields). Used by mayAutoPass. */ + public boolean isAutoPassingNoActions(PlayerView player) { + if (!getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return false; + if (autoPassInterrupted) return false; + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + if (gv != null && gv.getStack() != null && gv.getStack().isEmpty()) { + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType phase = gv.getPhase(); + if (turnPlayer != null && phase != null + && owner.getGui().isUiSetToSkipPhase(turnPlayer, phase)) { + return true; + } + } + return player != null && !player.hasAvailableActions(); + } + + private static boolean isBeingAttacked(CombatView combatView, PlayerView player) { + if (combatView == null) return false; + if (!combatView.getAttackersOf(player).isEmpty()) return true; + for (forge.game.GameEntityView defender : combatView.getDefenders()) { + if (defender instanceof CardView cardDefender && player.equals(cardDefender.getController()) && + !combatView.getAttackersOf(defender).isEmpty()) { + return true; + } + } + return false; + } + + private static boolean targetsPlayerOrPermanents(StackItemView si, PlayerView player) { + FCollectionView targetPlayers = si.getTargetPlayers(); + if (targetPlayers != null) { + for (PlayerView target : targetPlayers) { + if (target.equals(player)) return true; + } + } + FCollectionView targetCards = si.getTargetCards(); + if (targetCards != null) { + for (CardView target : targetCards) { + if (player.equals(target.getController())) return true; + } + } + StackItemView subInstance = si.getSubInstance(); + return subInstance != null && targetsPlayerOrPermanents(subInstance, player); + } + + private static boolean isMassRemoval(SpellAbilityStackInstance si) { + ApiType api = si.getSpellAbility().getApi(); + if (api == ApiType.DestroyAll + || api == ApiType.DamageAll + || api == ApiType.SacrificeAll + || api == ApiType.ChangeZoneAll) return true; + SpellAbilityStackInstance subInstance = si.getSubInstance(); + return subInstance != null && isMassRemoval(subInstance); + } + } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java index 7d44f545ed7..9a927dc9587 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java @@ -2,6 +2,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import java.io.Serializable; @@ -24,5 +25,6 @@ public record YieldStateSnapshot( Map abilityTriggerDecisions, boolean autoYieldsDisabled, boolean autoTriggersDisabled, - Map> skipPhases + Map> skipPhases, + Map prefOverrides ) implements Serializable {} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index a1a379fce33..d3d2df84295 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -2,6 +2,7 @@ import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import java.io.Serializable; @@ -16,11 +17,13 @@ public sealed interface YieldUpdate extends Serializable permits YieldUpdate.SetMarker, YieldUpdate.ClearMarker, YieldUpdate.StackYield, + YieldUpdate.SetAutoPassUntilEndOfTurn, YieldUpdate.CardAutoYield, YieldUpdate.TriggerDecision, YieldUpdate.SetDisableYields, YieldUpdate.SetDisableTriggers, YieldUpdate.SkipPhase, + YieldUpdate.SetYieldPref, YieldUpdate.SeedFromClient { /** {@code atOrPastAtClick}: priority was at-or-past target on owner's turn when the user clicked — computed by the UI so client cache and host PCH initialize identically. */ @@ -28,7 +31,9 @@ record SetMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick record ClearMarker(PlayerView player) implements YieldUpdate {} - record StackYield(PlayerView player, boolean active) implements YieldUpdate {} + record StackYield(PlayerView player, boolean active, boolean respectsInterrupts) implements YieldUpdate {} + + record SetAutoPassUntilEndOfTurn(PlayerView player, boolean active) implements YieldUpdate {} record CardAutoYield(String cardKey, boolean active, boolean abilityScope) implements YieldUpdate {} @@ -43,5 +48,8 @@ record SetDisableTriggers(boolean disabled) implements YieldUpdate {} record SkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} + /** Pref values are stored String-typed in {@link forge.localinstance.properties.PreferencesStore}; callers wrap booleans with {@code String.valueOf} at the call site. */ + record SetYieldPref(FPref pref, String value) implements YieldUpdate {} + record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java index 2b02c874848..65e0ae93ef5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java @@ -4,8 +4,6 @@ import forge.game.player.Player; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbility; -import forge.gamemodes.match.YieldController; -import forge.gamemodes.match.YieldUpdate; import forge.gui.FThreads; import forge.player.PlayerControllerHuman; import forge.util.ITriggerEvent; @@ -93,22 +91,7 @@ public void selectButtonOK() { } @Override public void selectButtonCancel() { - // autoPassCancel first: its mayAutoPass gate needs at least one mode still active to do UI updates. - controller.autoPassCancel(); - YieldController yc = controller.getYieldController(); - PlayerView pv = controller.getLocalPlayerView(); - if (yc.getAutoPassUntilMarker() != null) { - yc.clearMarker(); - if (controller.getGui() != null) { - controller.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(pv)); - } - } - if (yc.autoPassUntilStackEmpty()) { - yc.setAutoPassUntilStackEmpty(false); - if (controller.getGui() != null) { - controller.getGui().applyYieldUpdate(new YieldUpdate.StackYield(pv, false)); - } - } + controller.getYieldController().clearActiveYieldAndDispatch(); } @Override diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java index 8de133bbe16..06abf028d11 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java @@ -18,13 +18,22 @@ package forge.gamemodes.match.input; import forge.game.Game; +import forge.game.GameView; import forge.game.card.Card; +import forge.game.phase.PhaseType; import forge.game.player.Player; +import forge.game.player.PlayerView; import forge.game.player.actions.PassPriorityAction; import forge.game.spellability.SpellAbility; +import forge.game.spellability.StackItemView; +import forge.gamemodes.match.DeclineScope; +import forge.gamemodes.match.SuggestionType; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.server.FServerManager; import forge.gamemodes.net.server.FServerManager.AfkTimeout; import forge.localinstance.properties.ForgePreferences.FPref; +import forge.util.collect.FCollectionView; import forge.model.FModel; import forge.player.GamePlayerUtil; import forge.player.PlayerControllerHuman; @@ -49,6 +58,9 @@ public class InputPassPriority extends InputSyncronizedBase { private List chosenSa; + private SuggestionType pendingSuggestion = null; + private String pendingSuggestionMessage = null; + public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } @@ -69,6 +81,83 @@ public void showAndWait() { /** {@inheritDoc} */ @Override public final void showMessage() { + if (!isAlreadyYielding()) { + // Suppress one prompt after a yield ends — avoids "yielded → ended → yield again?" loop + if (getController().getYieldController().getBoolPref(FPref.YIELD_SUPPRESS_AFTER_END) + && getController().getYieldController().didYieldJustEnd()) { + showNormalPrompt(); + return; + } + + if (getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + showNormalPrompt(); + return; + } + + // Both scopes NEVER → skip including stack-transition tracking (no decline state to maintain) + DeclineScope stackScope = getController().getYieldController().getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD); + DeclineScope noActionsScope = getController().getYieldController().getDeclineScope(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + if (stackScope == DeclineScope.NEVER && noActionsScope == DeclineScope.NEVER) { + showNormalPrompt(); + return; + } + + GameView gvForStack = getGameView(); + boolean stackNonEmpty = gvForStack != null && gvForStack.getStack() != null + && !gvForStack.getStack().isEmpty(); + getController().getYieldController().onPriorityReceived(stackNonEmpty); + + Localizer loc = Localizer.getInstance(); + + if (!getController().getYieldController().isSuggestionDeclined(SuggestionType.STACK_YIELD) + && shouldShowStackYieldPrompt()) { + pendingSuggestion = SuggestionType.STACK_YIELD; + pendingSuggestionMessage = loc.getMessage("lblCannotRespondToStackYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + if (!getController().getYieldController().isSuggestionDeclined(SuggestionType.NO_ACTIONS) + && shouldShowNoActionsPrompt()) { + pendingSuggestion = SuggestionType.NO_ACTIONS; + pendingSuggestionMessage = loc.getMessage("lblNoActionsAvailableYieldPrompt"); + showYieldSuggestionPrompt(); + return; + } + } + + showNormalPrompt(); + } + + private void showYieldSuggestionPrompt() { + // State may have flipped between the initial check and now (e.g. async multiplayer click). + if (isAlreadyYielding()) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + + Localizer loc = Localizer.getInstance(); + String fullMessage = pendingSuggestionMessage; + DeclineScope scope = getController().getYieldController().getDeclineScope(pendingSuggestion.scopePref()); + if (scope == DeclineScope.STACK) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHintStack"); + } else if (scope == DeclineScope.TURN) { + fullMessage += "\n" + loc.getMessage("lblYieldSuggestionDeclineHint"); + } + showMessage(fullMessage); + chosenSa = null; + getController().getGui().updateButtons(getOwner(), + loc.getMessage("lblAccept"), + loc.getMessage("lblDecline"), + true, true, true); + getController().getGui().alertUser(); + } + + private void showNormalPrompt() { + pendingSuggestion = null; + pendingSuggestionMessage = null; + showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); @@ -82,9 +171,91 @@ public final void showMessage() { getController().getGui().alertUser(); } + private boolean isAlreadyYielding() { + return getController().getYieldController().isYieldActive(); + } + + private GameView getGameView() { + return getController().getGui().getGameView(); + } + + private boolean checkHasAvailableActions() { + Player player = getController().getPlayer(); + if (player == null) return false; + // Freshened upstream in chooseSpellAbilityToPlay; don't recompute + return player.getView().hasAvailableActions(); + } + + private boolean shouldShowStackYieldPrompt() { + GameView gv = getGameView(); + if (gv == null) return false; + FCollectionView stack = gv.getStack(); + if (stack == null || stack.isEmpty()) return false; + return !checkHasAvailableActions(); + } + + private boolean shouldShowNoActionsPrompt() { + GameView gv = getGameView(); + PlayerView pv = getOwner(); + if (gv == null || pv == null) return false; + FCollectionView stack = gv.getStack(); + if (stack != null && !stack.isEmpty()) return false; + PlayerView currentTurn = gv.getPlayerTurn(); + if (currentTurn != null && currentTurn.equals(pv)) { + // Always suppress on player's first turn (no lands/mana yet) + if (gv.getTurn() <= gv.getPlayers().size()) return false; + if (getController().getYieldController().getBoolPref(FPref.YIELD_SUPPRESS_ON_OWN_TURN)) return false; + } + return !checkHasAvailableActions(); + } + /** {@inheritDoc} */ @Override protected final void onOk() { + if (pendingSuggestion != null) { + // Defensive: state may have flipped (e.g. async multiplayer click). + if (isAlreadyYielding()) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + stop(); + return; + } + // APINA flipped on between display and accept — drop the now-redundant accept (host-local only) + if (!getController().isRemoteClient() + && getController().getYieldController().getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) { + pendingSuggestion = null; + pendingSuggestionMessage = null; + stop(); + return; + } + SuggestionType accepted = pendingSuggestion; + pendingSuggestion = null; + pendingSuggestionMessage = null; + + PlayerView self = getOwner(); + YieldController yc = getController().getYieldController(); + if (accepted == SuggestionType.STACK_YIELD) { + yc.setAutoPassUntilStackEmpty(true, true); + if (self != null) getController().getGui().applyYieldUpdate( + new YieldUpdate.StackYield(self, true, true)); + } else if (accepted == SuggestionType.NO_ACTIONS) { + // UPKEEP because UNTAP has no priority pass — a marker on UNTAP could never fire. + if (self != null) { + boolean atOrPast = YieldController.isPriorityAtOrPastMarker( + getGameView(), self, PhaseType.UPKEEP); + yc.setMarker(self, PhaseType.UPKEEP, atOrPast); + getController().getGui().applyYieldUpdate( + new YieldUpdate.SetMarker(self, PhaseType.UPKEEP, atOrPast)); + } + } + if (isAlreadyYielding()) { + stop(); + } else { + showNormalPrompt(); + } + return; + } + passPriority(() -> { getController().macros().addRememberedAction(new PassPriorityAction()); stop(); @@ -94,6 +265,14 @@ protected final void onOk() { /** {@inheritDoc} */ @Override protected final void onCancel() { + if (pendingSuggestion != null) { + getController().getYieldController().declineSuggestion(pendingSuggestion); + pendingSuggestion = null; + pendingSuggestionMessage = null; + showNormalPrompt(); + return; + } + if (!getController().tryUndoLastAction()) { //undo if possible //otherwise end turn passPriority(() -> { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index d8842f6033e..f0335bf6922 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -621,8 +621,8 @@ protected final void pushSkipPhaseToControllers(final PlayerView player, final P /** * Replace the host's persistent yield state for each controlled player - * in one atomic message: auto-yields and trigger-disabled flag from the - * AutoYieldStore, skip-phase prefs from PhaseLabel state. Per-key edits + * in one atomic message: auto-yields from the AutoYieldStore, + * skip-phase prefs from PhaseLabel state. Per-key edits * during play flow as individual YieldUpdate deltas. */ protected final void seedYieldStateOnHost() { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index be48b294cce..64ea0df6a72 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -87,7 +87,6 @@ public enum ProtocolMethod implements IHasForgeLog { selectButtonOk (Mode.CLIENT, Void.TYPE), selectButtonCancel (Mode.CLIENT, Void.TYPE), selectAbility (Mode.CLIENT, Void.TYPE, SpellAbilityView.class), - passPriorityUntilEndOfTurn(Mode.CLIENT, Void.TYPE), passPriority (Mode.CLIENT, Void.TYPE), nextGameDecision (Mode.CLIENT, Void.TYPE, NextGameDecision.class), getActivateDescription (Mode.CLIENT, String.class, CardView.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 34c06dd7e8d..539b3173d59 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -8,13 +8,11 @@ import forge.trackable.TrackableTypes; import forge.trackable.TrackableTypes.TrackableType; import forge.trackable.Tracker; +import forge.util.IHasForgeLog; import io.netty.handler.codec.serialization.ClassResolver; import io.netty.handler.codec.serialization.ClassResolvers; -import org.tinylog.Logger; -import org.tinylog.TaggedLogger; - import net.jpountz.lz4.LZ4BlockOutputStream; import java.io.*; @@ -30,8 +28,7 @@ * {@link CompatibleObjectDecoder}) via the underlying object streams * ({@link CObjectOutputStream}, {@link CObjectInputStream}). */ -public final class TrackableSerializer { - private static final TaggedLogger netLog = Logger.tag("NETWORK"); +public final class TrackableSerializer implements IHasForgeLog { private static final ClassResolver INNER_CLASS_RESOLVER = ClassResolvers.cacheDisabled(null); @@ -171,10 +168,8 @@ public static int measureSize(Object obj, Tracker tracker) { * full object graphs. Unwrapped after delta state is applied, when the * client tracker is populated. */ - static final class WrappedEvent implements Serializable { + record WrappedEvent(byte[] data) implements Serializable { private static final long serialVersionUID = 1L; - final byte[] data; - WrappedEvent(byte[] data) { this.data = data; } } /** 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 79e24ceab62..7def41f4957 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -13,6 +13,7 @@ import forge.interfaces.IDevModeCheats; import forge.interfaces.IGameController; import forge.interfaces.IMacroSystem; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore; import forge.util.ITriggerEvent; @@ -83,11 +84,6 @@ public void selectAbility(final SpellAbilityView sa) { send(ProtocolMethod.selectAbility, sa); } - @Override - public void passPriorityUntilEndOfTurn() { - send(ProtocolMethod.passPriorityUntilEndOfTurn); - } - @Override public void passPriority() { send(ProtocolMethod.passPriority); @@ -141,40 +137,24 @@ public void requestResync() { send(ProtocolMethod.requestResync); } - @Override - public boolean shouldAutoYield(final String key) { - return yieldController.shouldAutoYield(key); - } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { String storageKey = yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.CardAutoYield(storageKey, autoYield, isAbilityScope)); } - @Override - public boolean getDisableAutoYields() { - return yieldController.getDisableAutoYields(); - } @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetDisableYields(disable)); } - @Override - public AutoYieldStore.TriggerDecision getTriggerDecision(final String key) { - return yieldController.getTriggerDecision(key); - } @Override public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDecision decision, final boolean isAbilityScope) { String storageKey = yieldController.setTriggerDecision(key, decision, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(storageKey, decision, isAbilityScope)); } - @Override - public boolean getDisableAutoTriggers() { - return yieldController.getDisableAutoTriggers(); - } @Override public void setDisableAutoTriggers(final boolean disable) { yieldController.setDisableAutoTriggers(disable); @@ -187,15 +167,7 @@ public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType ph @Override public void applyYieldUpdate(final YieldUpdate update) { - // Local self-apply for marker/stack-yield user actions that route through - // sendYieldUpdate. Other cases dispatch via dedicated setters above. - if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); - } else if (update instanceof YieldUpdate.ClearMarker) { - yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.StackYield u) { - yieldController.setAutoPassUntilStackEmpty(u.active()); - } + yieldController.apply(update); } /** @@ -217,6 +189,11 @@ public void seedYieldStateOnHost(Map> skipPhases) send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); } + @Override + public void setYieldPref(final FPref pref, final String value) { + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetYieldPref(pref, value)); + } + private IMacroSystem macros; @Override public IMacroSystem macros() { diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index 32c5cd4e6a0..04b12b0758c 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -4,10 +4,13 @@ import com.google.common.collect.Maps; import com.google.common.collect.Multimap; import com.google.common.eventbus.Subscribe; +import forge.game.GameView; import forge.game.card.CardView; import forge.game.event.*; import forge.game.player.PlayerView; +import forge.game.spellability.SpellAbilityStackInstance; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldController; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; import forge.localinstance.properties.ForgeConstants; @@ -259,6 +262,8 @@ public Void visit(final GameEventGameFinished ev) { @Override public Void visit(final GameEventSpellAbilityCast event) { + evaluateYieldInterruptForSpellCast(event); + needStackUpdate = true; if (matchController.isLibgdxPort() || ForgeConstants.STACK_EFFECT_NOTIFICATION_NEVER.equals(FModel.getPreferences().getPref(FPref.UI_STACK_EFFECT_NOTIFICATION_POLICY))) { @@ -273,6 +278,22 @@ public Void visit(final GameEventSpellAbilityCast event) { return null; } + private void evaluateYieldInterruptForSpellCast(GameEventSpellAbilityCast event) { + if (humanController == null) return; + YieldController yc = humanController.getYieldController(); + if (!yc.isYieldActive()) return; + GameView gv = matchController.getGameView(); + if (gv == null || gv.getGame() == null) return; + // Look up the actual SpellAbilityStackInstance by id (host-side; client gv.getGame() is null). + int targetId = event.si().getId(); + for (SpellAbilityStackInstance candidate : gv.getGame().getStack()) { + if (candidate.getId() == targetId) { + yc.onSpellAbilityCast(candidate); + return; + } + } + } + @Override public Void visit(final GameEventSpellResolved event) { needStackUpdate = true; @@ -343,7 +364,6 @@ public Void visit(final GameEventCardCounters event) { @Override public Void visit(final GameEventBlockersDeclared event) { final Set cards = new HashSet<>(); - for (final Multimap kv : event.blockers().values()) { cards.addAll(kv.values()); } @@ -352,6 +372,13 @@ public Void visit(final GameEventBlockersDeclared event) { @Override public Void visit(final GameEventAttackersDeclared event) { + if (humanController != null) { + YieldController yc = humanController.getYieldController(); + if (yc.isYieldActive()) { + GameView gv = matchController.getGameView(); + if (gv != null && gv.getCombat() != null) yc.onAttackersDeclared(gv.getCombat()); + } + } return processCards(event.attackersMap().values(), cardsUpdate); } diff --git a/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java b/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java index e3925482f3f..27a0e54eccf 100644 --- a/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java +++ b/forge-gui/src/main/java/forge/gui/control/WatchLocalGame.java @@ -62,10 +62,6 @@ public void selectButtonCancel() { public void passPriority() { } - @Override - public void passPriorityUntilEndOfTurn() { - } - @Override public void useMana(final byte mana) { } diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 91e604e84b5..57ceb28ed06 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -8,6 +8,7 @@ import forge.gamemodes.match.NextGameDecision; import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldUpdate; +import forge.localinstance.properties.ForgePreferences.FPref; import forge.player.AutoYieldStore.TriggerDecision; import forge.util.ITriggerEvent; @@ -52,23 +53,24 @@ public interface IGameController { void requestResync(); void passPriority(); - void passPriorityUntilEndOfTurn(); // Auto-yield preferences - boolean shouldAutoYield(String key); + default boolean shouldAutoYield(String key) { + return getYieldController().shouldAutoYield(key); + } /** * @param isAbilityScope true if {@code key} is an ability suffix (Per Ability * modes); * false if {@code key} is the full raw key (Per Card mode). Server-side handlers * route storage by this flag instead of consulting the host's own UI_AUTO_DECISION_MODE. */ void setShouldAutoYield(String key, boolean autoYield, boolean isAbilityScope); - boolean getDisableAutoYields(); void setDisableAutoYields(boolean disable); // Trigger accept/decline preferences - TriggerDecision getTriggerDecision(String key); + default TriggerDecision getTriggerDecision(String key) { + return getYieldController().getTriggerDecision(key); + } void setTriggerDecision(String key, TriggerDecision decision, boolean isAbilityScope); - boolean getDisableAutoTriggers(); void setDisableAutoTriggers(boolean disable); /** Apply a unified yield update envelope to this controller's YieldController. */ @@ -83,4 +85,7 @@ default void sendYieldUpdate(YieldUpdate update) { } YieldController getYieldController(); + + /** Setter dispatches the per-PCH envelope; reads go via getYieldController(). Pref values are String-typed in {@link forge.localinstance.properties.PreferencesStore}, so callers wrap booleans with {@code String.valueOf}. */ + void setYieldPref(FPref pref, String value); } diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index f0301e26931..fb1f79d7c8d 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -144,6 +144,21 @@ public enum FPref implements PreferencesStore.IPref { UI_ALT_PLAYERINFOLAYOUT ("false"), UI_ALT_PLAYERZONETABS ("false"), UI_PRESELECT_PREVIOUS_ABILITY_ORDER ("false"), + YIELD_INTERRUPT_ON_ATTACKERS ("true"), + YIELD_INTERRUPT_ON_OPPONENT_SPELL ("true"), + YIELD_INTERRUPT_ON_TARGETING ("false"), + YIELD_INTERRUPT_ON_TRIGGERS ("false"), + YIELD_INTERRUPT_ON_REVEAL ("false"), + YIELD_INTERRUPT_ON_MASS_REMOVAL ("false"), + YIELD_AUTO_PASS_NO_ACTIONS ("false"), + YIELD_AUTO_PASS_RESPECTS_INTERRUPTS ("false"), + YIELD_AVAILABLE_ACTIONS_BUDGET_MS ("0"), + YIELD_SKIP_PHASE_DELAY ("false"), + YIELD_SKIP_RESOLVE_DELAY ("false"), + YIELD_SUPPRESS_ON_OWN_TURN ("true"), + YIELD_SUPPRESS_AFTER_END ("true"), + YIELD_DECLINE_SCOPE_STACK_YIELD ("NEVER"), + YIELD_DECLINE_SCOPE_NO_ACTIONS ("NEVER"), UI_AUTO_DECISION_MODE (ForgeConstants.AUTO_DECISION_PER_ABILITY), UI_SHOW_STORM_COUNT_IN_PROMPT ("false"), UI_REMIND_ON_PRIORITY ("false"), @@ -305,6 +320,8 @@ public enum FPref implements PreferencesStore.IPref { SHORTCUT_SHOWTARGETING ("84"), SHORTCUT_AUTOYIELD_ALWAYS_YES ("89"), SHORTCUT_AUTOYIELD_ALWAYS_NO ("78"), + SHORTCUT_YIELD_OPTIONS ("17 89"), + SHORTCUT_YIELD_AUTO_PASS ("80"), SHORTCUT_MACRO_RECORD ("16 82"), SHORTCUT_MACRO_NEXT_ACTION ("16 50"), SHORTCUT_CARD_ZOOM("90"), diff --git a/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java b/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java index 90ed6c0f10e..b7cebe91a70 100644 --- a/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java +++ b/forge-gui/src/main/java/forge/localinstance/skin/FSkinProp.java @@ -267,6 +267,7 @@ public enum FSkinProp { ICO_ARCSOFF (new int[] {240, 800, 80, 80}, PropType.ICON), ICO_ARCSON (new int[] {320, 800, 80, 80}, PropType.ICON), ICO_ARCSHOVER (new int[] {400, 800, 80, 80}, PropType.ICON), + ICO_AUTOPASS (new int[] {400, 720, 80, 80}, PropType.ICON), //choice-search-misc ICO_HDCHOICE (new int[] {2, 1792, 128, 128}, PropType.BUTTONS), diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 10920a844ef..d80221715e8 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3,8 +3,10 @@ import com.google.common.collect.*; import forge.LobbyPlayer; import forge.StaticData; +import forge.ai.AvailableActions; import forge.ai.GameState; import forge.ai.PlayerControllerAi; +import forge.gamemodes.net.server.RemoteClientGuiGame; import forge.card.*; import forge.card.mana.ManaCost; import forge.card.mana.ManaCostShard; @@ -47,6 +49,7 @@ import forge.game.zone.PlayerZone; import forge.game.zone.Zone; import forge.game.zone.ZoneType; +import forge.gamemodes.match.DeclineScope; import forge.gamemodes.match.NextGameDecision; import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldUpdate; @@ -919,6 +922,7 @@ public void reveal(final List cards, final ZoneType zone, final Player } protected void reveal(final CardCollectionView cards, final ZoneType zone, final PlayerView owner, String message, boolean addSuffix) { + yieldController.maybeInterruptOnReveal(); if (StringUtils.isBlank(message)) { message = localizer.getMessage("lblLookCardInPlayerZone", "{player's}", zone.getTranslatedName().toLowerCase()); } else if (addSuffix) { @@ -1489,13 +1493,15 @@ public CardCollectionView tuckCardsViaMulligan(CardCollectionView hand, int card @Override public void declareAttackers(final Player attackingPlayer, final Combat combat) { - if (mayAutoPass()) { + // CombatUtil.canAttack ensures we only get here when the player has a legal attacker + // so APINA must never skip - the invocation itself is the available action. User + // initiated yields (pass until end of turn) still skip when not-attacking is legal. + if (yieldController.shouldAutoYield()) { if (CombatUtil.validateAttackers(combat)) { - return; // don't prompt to declare attackers if user chose to - // end the turn and not attacking is legal + return; } - // otherwise: cancel auto pass because of this unexpected attack - autoPassCancel(); + // if forced to prompt (must-attack/goad) clear the yield like any other interrupt + yieldController.applyInterrupt(); } // This input should not modify combat object itself, but should return user choice @@ -1517,7 +1523,18 @@ public List chooseSpellAbilityToPlay() { player.getName(), getGame().getPhaseHandler().getPhase(), getGame().isGameOver()); final MagicStack stack = getGame().getStack(); - if (mayAutoPass()) { + // Skip when already yielding — yield proceeds regardless of available-actions. + // Yield check first: it's a field read, vs needsAvailableActions which does 3 synced FPref reads. + if (!yieldController.isYieldActive() && needsAvailableActions()) { + long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + } + + // yieldJustEndedFlag is read from the EDT (didYieldJustEnd); synchronized writer/reader pair handles visibility. + boolean nowMayAutoPass = mayAutoPass(); + yieldController.noteMayAutoPassResult(nowMayAutoPass); + + if (nowMayAutoPass) { // avoid prompting for input if current phase is set to be // auto-passed instead posing a short delay if needed to // prevent the game jumping ahead too quick @@ -1525,10 +1542,11 @@ public List chooseSpellAbilityToPlay() { if (stack.isEmpty()) { // make sure to briefly pause at phases you're not set up to skip if (!isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), - getGame().getPhaseHandler().getPhase())) { + getGame().getPhaseHandler().getPhase()) + && !yieldController.getBoolPref(FPref.YIELD_SKIP_PHASE_DELAY)) { delay = FControlGamePlayback.phasesDelay; } - } else { + } else if (!yieldController.getBoolPref(FPref.YIELD_SKIP_RESOLVE_DELAY)) { // pause slightly longer for spells and abilities on the stack resolving delay = FControlGamePlayback.resolveDelay; } @@ -1539,12 +1557,14 @@ public List chooseSpellAbilityToPlay() { e.printStackTrace(); } } + getGui().awaitNextInput(); netLog.trace("Returning null (mayAutoPass) for player {}", player.getName()); return null; } if (stack.isEmpty()) { if (isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), getGame().getPhaseHandler().getPhase())) { + getGui().awaitNextInput(); netLog.trace("Returning null (skipPhase) for player {}", player.getName()); return null; // avoid prompt for input if stack is empty and // player is set to skip the current phase @@ -1558,6 +1578,7 @@ public List chooseSpellAbilityToPlay() { } catch (final InterruptedException e) { e.printStackTrace(); } + getGui().awaitNextInput(); netLog.trace("Returning null (autoYield) for player {}", player.getName()); return null; } @@ -1723,6 +1744,7 @@ public Pair chooseTarget(final SpellAbili @Override public void notifyOfValue(final SpellAbility sa, final GameObject realtedTarget, final String value) { + yieldController.maybeInterruptOnReveal(); final String message = MessageUtil.formatNotificationMessage(sa, player, realtedTarget, value); if (sa != null && sa.isManaAbility()) { getGame().fireEvent(new GameEventAddLog(GameLogEntryType.LAND, message)); @@ -2406,25 +2428,9 @@ public void selectButtonCancel() { @Override public void passPriority() { - passPriority(false); - } - - @Override - public void passPriorityUntilEndOfTurn() { - passPriority(true); - } - - private void passPriority(final boolean passUntilEndOfTurn) { final Input inp = inputProxy.getInput(); if (inp instanceof InputPassPriority) { - if (passUntilEndOfTurn) { - autoPassUntilEndOfTurn(); - } inp.selectButtonOK(); - } else { - FThreads.invokeInEdtNowOrLater(() -> { - // getGui().message("Cannot pass priority at this time."); - }); } } @@ -3477,15 +3483,16 @@ public void requestResync() { } public boolean isRemoteClient() { - return gui instanceof forge.gamemodes.net.server.RemoteClientGuiGame; + return gui instanceof RemoteClientGuiGame; } public boolean mayAutoPass() { - return yieldController.shouldAutoYield(); + return yieldController.shouldAutoYield() + || yieldController.isAutoPassingNoActions(getLocalPlayerView()); } public void autoPassUntilEndOfTurn() { - yieldController.setAutoPassUntilEOTWithoutInterruptions(true); + yieldController.setAutoPassUntilEndOfTurn(true); getGui().updateAutoPassPrompt(); } @@ -3494,36 +3501,23 @@ public void autoPassCancel() { if (!mayAutoPass()) { return; } - yieldController.setAutoPassUntilEOTWithoutInterruptions(false); + yieldController.setAutoPassUntilEndOfTurn(false); PlayerView playerView = getLocalPlayerView(); getGui().showPromptMessage(playerView, ""); getGui().updateButtons(playerView, false, false, false); getGui().awaitNextInput(); } - @Override - public boolean shouldAutoYield(final String key) { - return yieldController.shouldAutoYield(key); - } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); } - @Override - public boolean getDisableAutoYields() { - return yieldController.getDisableAutoYields(); - } @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); } - @Override - public AutoYieldStore.TriggerDecision getTriggerDecision(final String key) { - return yieldController.getTriggerDecision(key); - } - @Override public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDecision decision, final boolean isAbilityScope) { yieldController.setTriggerDecision(key, decision, isAbilityScope); @@ -3538,10 +3532,6 @@ public void setTriggerDecision(final String key, final AutoYieldStore.TriggerDec } } - @Override - public boolean getDisableAutoTriggers() { - return yieldController.getDisableAutoTriggers(); - } @Override public void setDisableAutoTriggers(final boolean disable) { yieldController.setDisableAutoTriggers(disable); @@ -3556,29 +3546,7 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p @Override public void applyYieldUpdate(final YieldUpdate update) { - boolean activatedYield = false; - if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); - activatedYield = true; - } else if (update instanceof YieldUpdate.ClearMarker) { - yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.StackYield u) { - yieldController.setAutoPassUntilStackEmpty(u.active()); - activatedYield = u.active(); - } else if (update instanceof YieldUpdate.TriggerDecision u) { - yieldController.applyTriggerDecisionFromWire(u.storageKey(), u.decision()); - } else if (update instanceof YieldUpdate.CardAutoYield u) { - yieldController.applyAutoYieldFromWire(u.cardKey(), u.active()); - } else if (update instanceof YieldUpdate.SkipPhase u) { - yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); - } else if (update instanceof YieldUpdate.SetDisableYields u) { - yieldController.setDisableAutoYields(u.disabled()); - } else if (update instanceof YieldUpdate.SetDisableTriggers u) { - yieldController.setDisableAutoTriggers(u.disabled()); - } else if (update instanceof YieldUpdate.SeedFromClient u) { - yieldController.applyClientSeed(u.snapshot()); - } - if (activatedYield) { + if (yieldController.apply(update)) { // Switch the cancel button + prompt to "Yielding until X" so the user can disarm. // Otherwise the previous InputPassPriority "End Turn" label would persist and ESC // would skip the click on the client. @@ -3591,5 +3559,45 @@ public void applyYieldUpdate(final YieldUpdate update) { getGui().updateAutoPassPrompt(); } } + tryAutoPassNow(); + } + + @Override + public void setYieldPref(final FPref pref, final String value) { + // Dialog already wrote to FModel; APINA is the only pref whose toggle can flip mayAutoPass for a sitting prompt + if (pref == FPref.YIELD_AUTO_PASS_NO_ACTIONS) tryAutoPassNow(); + } + + /** Re-evaluate mayAutoPass at the current prompt; click OK if it would now fire. + * Same compute gating as {@link #chooseSpellAbilityToPlay} so the actions field is fresh. */ + private void tryAutoPassNow() { + if (!(inputQueue.getInput() instanceof InputPassPriority)) return; + if (!yieldController.isYieldActive() && needsAvailableActions()) { + long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + } + if (mayAutoPass()) { + selectButtonOk(); + } + } + + /** True if yield consumer needs the synced wire field. */ + private boolean needsAvailableActions() { + if (yieldController.getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS)) return true; + if (yieldController.getDeclineScope(FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS) != DeclineScope.NEVER) return true; + if (yieldController.getDeclineScope(FPref.YIELD_DECLINE_SCOPE_STACK_YIELD) != DeclineScope.NEVER) return true; + return false; + } + + /** Pref > 0 = explicit override; 0 = Dynamic = 50ms × playable cards, clamped to [50, 1500]. */ + private long computeAvailableActionsBudgetMs(Player p) { + String prefStr = yieldController.getStringPref(FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS); + int prefMs; + try { prefMs = Integer.parseInt(prefStr); } catch (NumberFormatException e) { prefMs = 0; } + if (prefMs > 0) return prefMs; + int cardCount = p.getCardsIn(ZoneType.Hand).size() + + p.getCardsIn(ZoneType.Battlefield).size() + + p.getCardsIn(ZoneType.Flashback).size(); + return Math.min(1500L, Math.max(50L, 50L * cardCount)); } }