From cd1ea6b246ac44f1c773b2ec1c1a52629154725e Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:52:30 -0700 Subject: [PATCH 1/7] Add playable card highlights A light blue outline ("weakly selectable") on cards the player can currently play or activate. Reads the same AvailableActions heuristic as APINA, so the highlight set and the auto-pass decision come from one pass per priority window. - IGuiGame: setWeaklySelectable / clearWeaklySelectable; default implementations on AbstractGuiGame; remote-proxy implementations on RemoteClientGuiGame ship over the wire as new ProtocolMethods. - PlayerControllerHuman: - cachedActionableCards from chooseSpellAbilityToPlay reused by pushActionableCards when not in payment mode. - pushAttackerCandidates / pushBlockerCandidates compute candidate sets from CombatUtil for InputAttack / InputBlock. - Payment-mode pushActionableCards falls back to the simpler "card has a playable mana ability" predicate. - InputAttack / InputBlock: push candidates on showMessage and after every click; clear on stop. Stops highlights persisting through autopass on remote clients. - InputPassPriority / InputPayMana: push at message time, clear on stop, refresh on each cost component selection. - CardPanel (desktop) / CardRenderer (mobile): honor isWeaklySelectable with a thin blue outline in the existing render paths. - VSubmenuPreferences + CSubmenuPreferences: UI toggle for UI_SHOW_ACTIONABLE_HIGHLIGHTS in the prefs page. - en-US: cbShowActionableHighlights / nlShowActionableHighlights. Defaults to off; gated on UI_SHOW_ACTIONABLE_HIGHLIGHTS. --- .../main/java/forge/ai/AvailableActions.java | 80 ++++++++++ .../home/settings/CSubmenuPreferences.java | 1 + .../home/settings/VSubmenuPreferences.java | 9 ++ .../java/forge/screens/match/CMatchUI.java | 32 ++++ .../java/forge/view/arcane/CardPanel.java | 5 + .../src/forge/card/CardRenderer.java | 4 + .../forge/screens/match/MatchController.java | 30 ++++ .../forge/screens/settings/SettingsPage.java | 3 + forge-gui/res/languages/en-US.properties | 2 + .../gamemodes/match/AbstractGuiGame.java | 17 ++ .../gamemodes/match/input/InputAttack.java | 15 ++ .../gamemodes/match/input/InputBlock.java | 7 + .../match/input/InputPassPriority.java | 6 + .../gamemodes/match/input/InputPayMana.java | 4 + .../forge/gamemodes/net/ProtocolMethod.java | 2 + .../net/server/RemoteClientGuiGame.java | 12 ++ .../java/forge/gui/interfaces/IGuiGame.java | 4 + .../properties/ForgePreferences.java | 1 + .../forge/player/PlayerControllerHuman.java | 145 +++++++++++++++++- 19 files changed, 376 insertions(+), 3 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index be2d96b421c..16ec639c55c 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -2,11 +2,14 @@ import forge.game.card.Card; import forge.game.card.CardLists; +import forge.game.card.CardView; import forge.game.player.Player; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; import org.tinylog.Logger; +import java.util.HashSet; +import java.util.Set; import java.util.stream.Collectors; // Heuristic: does the player have any playable action this priority window? @@ -62,6 +65,83 @@ private static boolean scan(Player player, long deadlineNanos, long timeoutMs) { return false; } + /** + * Full-scan variant of {@link #compute} that returns the set of card + * views with at least one actionable SA. No early-exit: we walk + * every card so downstream highlight code can reuse the same + * per-card answers the APINA boolean is derived from. + * + * Timeout behavior: on expiry, remaining unvisited cards are added + * to the set (FP-safe — the player is shown extra highlights rather + * than missing some). The APINA boolean can be derived as + * {@code !result.isEmpty()}. + */ + public static Set collectActionable(Player player, long timeoutMs) { + long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; + Set actionable = new HashSet<>(); + + for (Card card : sortedCardsIn(player, ZoneType.Hand)) { + if (checkTimeout(deadlineNanos, timeoutMs)) { + addAllRemaining(actionable, player); + return actionable; + } + if (cardHasActionableSpell(card, player)) { + actionable.add(card.getView()); + } + } + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + if (checkTimeout(deadlineNanos, timeoutMs)) { + addAllRemaining(actionable, player); + return actionable; + } + if (cardHasActionableActivated(card, player)) { + actionable.add(card.getView()); + } + } + for (Card card : sortedCardsIn(player, ZoneType.Flashback)) { + if (checkTimeout(deadlineNanos, timeoutMs)) { + addAllRemaining(actionable, player); + return actionable; + } + if (cardHasActionableActivated(card, player)) { + actionable.add(card.getView()); + } + } + return actionable; + } + + private static boolean cardHasActionableSpell(Card card, Player player) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (sa.isSpell()) { + if (canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } else if (sa.isLandAbility()) { + return true; + } + } + return false; + } + + private static boolean cardHasActionableActivated(Card card, Player player) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (!sa.isManaAbility() && canAfford(sa, player) + && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; + } + } + return false; + } + + /** FP-safe timeout fallback: mark every card in a scannable zone as + * actionable. The player sees extra highlights rather than missing + * playable cards. */ + private static void addAllRemaining(Set actionable, Player player) { + for (Card c : player.getCardsIn(ZoneType.Hand)) actionable.add(c.getView()); + for (Card c : player.getCardsIn(ZoneType.Battlefield)) actionable.add(c.getView()); + for (Card c : player.getCardsIn(ZoneType.Flashback)) actionable.add(c.getView()); + } + // 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()); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 52f788ef821..24e43a41b28 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -109,6 +109,7 @@ public void initialize() { lstControls.add(Pair.of(view.getCbOrderCombatants(), FPref.LEGACY_ORDER_COMBATANTS)); lstControls.add(Pair.of(view.getCbScaleLarger(), FPref.UI_SCALE_LARGER)); lstControls.add(Pair.of(view.getCbRenderBlackCardBorders(), FPref.UI_RENDER_BLACK_BORDERS)); + lstControls.add(Pair.of(view.getCbShowActionableHighlights(), FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)); lstControls.add(Pair.of(view.getCbLargeCardViewers(), FPref.UI_LARGE_CARD_VIEWERS)); lstControls.add(Pair.of(view.getCbSmallDeckViewer(), FPref.UI_SMALL_DECK_VIEWER)); lstControls.add(Pair.of(view.getCbRandomArtInPools(), FPref.UI_RANDOM_ART_IN_POOLS)); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index e6be6e2acf5..b12b669efb5 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -90,6 +90,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbCloneImgSource = new OptionsCheckBox(localizer.getMessage("cbCloneImgSource")); private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); private final JCheckBox cbRenderBlackCardBorders = new OptionsCheckBox(localizer.getMessage("cbRenderBlackCardBorders")); + private final JCheckBox cbShowActionableHighlights = new OptionsCheckBox(localizer.getMessage("cbShowActionableHighlights")); private final JCheckBox cbLargeCardViewers = new OptionsCheckBox(localizer.getMessage("cbLargeCardViewers")); private final JCheckBox cbSmallDeckViewer = new OptionsCheckBox(localizer.getMessage("cbSmallDeckViewer")); private final JCheckBox cbDisplayFoil = new OptionsCheckBox(localizer.getMessage("cbDisplayFoil")); @@ -407,6 +408,9 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbRenderBlackCardBorders, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlRenderBlackCardBorders")), descriptionConstraints); + pnlPrefs.add(cbShowActionableHighlights, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlShowActionableHighlights")), descriptionConstraints); + pnlPrefs.add(cbLargeCardViewers, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlLargeCardViewers")), descriptionConstraints); @@ -815,6 +819,11 @@ public JCheckBox getCbRenderBlackCardBorders() { return cbRenderBlackCardBorders; } + /** @return {@link javax.swing.JCheckBox} */ + public JCheckBox getCbShowActionableHighlights() { + return cbShowActionableHighlights; + } + /** @return {@link javax.swing.JCheckBox} */ public JCheckBox getCbLargeCardViewers() { return cbLargeCardViewers; diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 729ef85b70f..6a73e38d48f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -692,6 +692,38 @@ public void setHighlighted(final Iterable entities, final boolea } } + @Override + public void setWeaklySelectable(final Iterable cards) { + super.setWeaklySelectable(cards); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if (p.getCards(ZoneType.Battlefield) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if (p.getCards(ZoneType.Hand) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + FloatingZone.refreshAll(); + }); + } + + @Override + public void clearWeaklySelectable() { + super.clearWeaklySelectable(); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if (p.getCards(ZoneType.Battlefield) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if (p.getCards(ZoneType.Hand) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + FloatingZone.refreshAll(); + }); + } + @Override public void refreshField() { super.refreshField(); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index 4ba3532cbd8..bee3da6db9a 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -379,6 +379,11 @@ protected final void paintComponent(final Graphics g) { g2d.setColor(Color.WHITE); final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); + } else if (isPreferenceEnabled(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && matchUI.isWeaklySelectable(getCard())) { + // Light blue soft outline for cards the player can currently act on. + g2d.setColor(new Color(0x66, 0xCC, 0xFF)); + final int ins = 1; + g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); } } diff --git a/forge-gui-mobile/src/forge/card/CardRenderer.java b/forge-gui-mobile/src/forge/card/CardRenderer.java index b0552f95e81..b8aa50b13d9 100644 --- a/forge-gui-mobile/src/forge/card/CardRenderer.java +++ b/forge-gui-mobile/src/forge/card/CardRenderer.java @@ -815,6 +815,10 @@ public static void drawCardWithOverlays(Graphics g, CardView card, float x, floa //Magenta outline when card is chosen if (MatchController.instance.isHighlighted(card)) { g.drawRect(BORDER_THICKNESS, Color.MAGENTA, cx, cy, cw, ch); + } else if (!unselectable && FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) + && MatchController.instance.isWeaklySelectable(card)) { + // Light blue soft outline for cards the player can currently act on. + g.drawRect(BORDER_THICKNESS, FSkinColor.fromRGB(0x66, 0xCC, 0xFF), cx, cy, cw, ch); } //Ability Icons if (unselectable) { diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 61e9da82759..c493735e998 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -546,6 +546,36 @@ public void clearSelectables() { }); } + @Override + public void setWeaklySelectable(final Iterable cards) { + super.setWeaklySelectable(cards); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if ( p.getCards(ZoneType.Battlefield) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if ( p.getCards(ZoneType.Hand) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + }); + } + + @Override + public void clearWeaklySelectable() { + super.clearWeaklySelectable(); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if ( p.getCards(ZoneType.Battlefield) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if ( p.getCards(ZoneType.Hand) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + }); + } + @Override public void afterGameEnd() { super.afterGameEnd(); diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index dab6848e69e..8ee2bdf3657 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -254,6 +254,9 @@ public void valueChanged(String newValue) { lstSettings.addItem(new BooleanSetting(FPref.UI_SHOW_STORM_COUNT_IN_PROMPT, Forge.getLocalizer().getMessage("cbShowStormCount"), Forge.getLocalizer().getMessage("nlShowStormCount")), 1); + lstSettings.addItem(new BooleanSetting(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS, + Forge.getLocalizer().getMessage("cbShowActionableHighlights"), + Forge.getLocalizer().getMessage("nlShowActionableHighlights")), 1); lstSettings.addItem(new CustomSelectSetting(FPref.UI_ALLOW_ORDER_GRAVEYARD_WHEN_NEEDED, Forge.getLocalizer().getMessage("lblOrderGraveyard"), Forge.getLocalizer().getMessage("nlOrderGraveyard"), diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 3ef3b9d9afc..4e22c832b29 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -108,6 +108,7 @@ cbImageFetcher=Automatically Download Missing Card Art cbCloneImgSource=Clones Use Original Card Art cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders +cbShowActionableHighlights=Highlight Actionable Cards cbLargeCardViewers=Use Large Card Viewers cbSmallDeckViewer=Use Small Deck Viewer cbDisplayFoil=Display Foil Overlay @@ -241,6 +242,7 @@ nlDisplayFoil=Displays foil cards with the visual foil overlay effect. nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. nlRenderBlackCardBorders=Render black borders around card images. +nlShowActionableHighlights=Show a light blue outline on cards you can currently play or activate. nlLargeCardViewers=Makes all card viewers much larger for use with high resolution images. Will not fit on smaller screens. nlSmallDeckViewer=Sets the deck viewer window to be 800x600 rather than a proportion of the screen size. nlRandomArtInPools=Generates cards with random art in generated limited mode card pools. 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 d9aa3e0305b..e12f76b2df9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -354,6 +354,23 @@ public int getSelectionMax() { return selectionMax; } + private final Set weaklySelectableCards = Sets.newHashSet(); + + public void setWeaklySelectable(final Iterable cards) { + weaklySelectableCards.clear(); + for (CardView cv : cards) { + weaklySelectableCards.add(cv); + } + } + + public void clearWeaklySelectable() { + weaklySelectableCards.clear(); + } + + public boolean isWeaklySelectable(final CardView card) { + return weaklySelectableCards.contains(card); + } + public boolean isGamePaused() { return gamePause; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java index 9aca0381c69..68cbb4cf438 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java @@ -68,6 +68,7 @@ public InputAttack(PlayerControllerHuman controller, Player attacks0, Combat com @Override public final void showMessage() { + getController().pushAttackerCandidates(playerAttacks, combat); setCurrentDefender(defenders.getFirst()); if (currentDefender == null) { @@ -95,6 +96,12 @@ private void disablePrompt() { getController().getGui().updateButtons(getOwner(), localizer.getMessage("lblDisabled"), localizer.getMessage("lblDisabled"), false, false, false); } + @Override + protected void onStop() { + // Highlights pushed in showMessage would otherwise persist on remote clients through autopass. + getController().clearActionableCards(); + } + @Override protected final void onOk() { // Propaganda costs could have been paid here. @@ -345,6 +352,14 @@ private void updateMessage() { updatePrompt(); + // Refresh the actionable-card highlight set after every click so + // declared attackers stop glowing (and call-backs restore the + // glow). Mirrors InputBlock's behavior, where its no-arg + // showMessage() override re-pushes blocker candidates after each + // selection. We can't call our own no-arg showMessage() here + // because it also resets currentDefender. + getController().pushAttackerCandidates(playerAttacks, combat); + if (combat != null) getController().getGame().fireEvent(GameEventCombatUpdate.fromCards(combat.getAttackers(), combat.getAllBlockers())); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java index bf60efabe51..2ed9a05a98c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java @@ -71,6 +71,7 @@ public InputBlock(final PlayerControllerHuman controller, final Player defender0 /** {@inheritDoc} */ @Override protected final void showMessage() { + getController().pushBlockerCandidates(defender, combat); // could add "Reset Blockers" button Localizer localizer = Localizer.getInstance(); getController().getGui().updateButtons(getOwner(), true, false, true); @@ -89,6 +90,12 @@ protected final void showMessage() { getController().getGui().showCombat(); } + @Override + protected void onStop() { + // Highlights pushed in showMessage would otherwise persist on remote clients through autopass. + getController().clearActionableCards(); + } + /** {@inheritDoc} */ @Override public final void onOk() { 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 a4ed7256084..ff17af66107 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 @@ -65,6 +65,11 @@ public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } + @Override + protected void onStop() { + getController().clearActionableCards(); + } + @Override public void showAndWait() { final FServerManager server = FServerManager.getInstance(); @@ -158,6 +163,7 @@ private void showNormalPrompt() { pendingSuggestion = null; pendingSuggestionMessage = null; + getController().pushActionableCards(false); showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java index 88c832d7116..aa91d3492ce 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java @@ -62,6 +62,7 @@ protected InputPayMana(final PlayerControllerHuman controller, final SpellAbilit @Override protected void onStop() { + getController().clearActionableCards(); if (!isFinished()) { // Clear current Mana cost being paid for SA saPaidFor.setManaCostBeingPaid(null); @@ -400,6 +401,8 @@ protected final void updateMessage() { if (activateDelayedCard()) { return; } + // Drop just-tapped sources from the highlight set. + getController().pushActionableCards(true); if (supportAutoPay()) { if (canPayManaCost == null) { //use AI utility to determine if mana cost can be paid if that hasn't been determined yet @@ -422,6 +425,7 @@ public Boolean evaluate() { @Override public void showMessage() { if (isFinished()) { return; } + getController().pushActionableCards(true); updateButtons(); onStateChanged(); } 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 a873f0ae574..680c2c87211 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -66,6 +66,8 @@ public enum ProtocolMethod implements IHasForgeLog { setCard (Mode.SERVER, Void.TYPE, CardView.class), setSelectables (Mode.SERVER, Void.TYPE, Iterable/*CardView*/.class, Integer.TYPE, Integer.TYPE), clearSelectables (Mode.SERVER, Void.TYPE), + setWeaklySelectable (Mode.SERVER, Void.TYPE, Iterable/*CardView*/.class), + clearWeaklySelectable(Mode.SERVER, Void.TYPE), // TODO case "setPlayerAvatar": openZones (Mode.SERVER, PlayerZoneUpdates.class, PlayerView.class, Collection/*ZoneType*/.class, Map/*PlayerView,Object*/.class, Boolean.TYPE), restoreOldZones (Mode.SERVER, Void.TYPE, PlayerView.class, PlayerZoneUpdates.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index fda619498b0..6d3d77435ee 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -498,6 +498,18 @@ public void clearSelectables() { syncAndSend(ProtocolMethod.clearSelectables); } + @Override + public void setWeaklySelectable(final Iterable cards) { + updateGameView(); + send(ProtocolMethod.setWeaklySelectable, cards); + } + + @Override + public void clearWeaklySelectable() { + updateGameView(); + send(ProtocolMethod.clearWeaklySelectable); + } + @Override public void setPlayerAvatar(final LobbyPlayer player, final IHasIcon ihi) { // TODO Auto-generated method stub diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 13f02014da5..da1f7ea7593 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -269,6 +269,10 @@ default List order(String title, String top, int remainingObjectsMin, int boolean isSelecting(); + void setWeaklySelectable(final Iterable cards); + + void clearWeaklySelectable(); + boolean isGamePaused(); void setGamePause(boolean pause); 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 10b61e380c1..82f4b92b2a9 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -109,6 +109,7 @@ public enum FPref implements PreferencesStore.IPref { UI_TOKENS_IN_SEPARATE_ROW("false"), UI_SCALE_LARGER ("true"), UI_RENDER_BLACK_BORDERS ("true"), + UI_SHOW_ACTIONABLE_HIGHLIGHTS ("false"), UI_LARGE_CARD_VIEWERS ("false"), UI_RANDOM_ART_IN_POOLS ("true"), UI_COMPACT_PROMPT ("false"), diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index fcbb2a2f4d5..f9a5584f81f 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1557,6 +1557,133 @@ public void declareBlockers(final Player defender, final Combat combat) { getGui().updateAutoPassPrompt(); } + private static final ZoneType[] ACTIONABLE_PAYMENT_ZONES = new ZoneType[] { + ZoneType.Hand, ZoneType.Battlefield, ZoneType.Graveyard, ZoneType.Exile, ZoneType.Command + }; + + /** Actionable card set cached from the last {@link AvailableActions#collectActionable} + * call inside {@link #chooseSpellAbilityToPlay}. {@code pushActionableCards} + * reads this in non-payment mode so the highlights and the APINA + * boolean come from the same heuristic pass — no second scan. */ + private Set cachedActionableCards; + + /** + * Push the set of actionable cards to the GUI. In non-payment mode, + * reuses the set that {@link AvailableActions#collectActionable} + * populated during the APINA pass — same AI heuristic, no rerun. + * In payment mode (inside a cost payment), the AI check is not + * meaningful (you're already paying); we fall back to the simpler + * "card has a playable mana ability" predicate. + * + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS} — when off, + * the highlight set is cleared. + */ + public void pushActionableCards(boolean paymentMode) { + if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + + if (paymentMode) { + final Set result = Sets.newHashSet(); + for (ZoneType zone : ACTIONABLE_PAYMENT_ZONES) { + for (Card c : player.getCardsIn(zone)) { + if (cardHasPlayableManaAbility(c)) { + result.add(c.getView()); + } + } + } + getGui().setWeaklySelectable(result); + return; + } + + // Normal priority: reuse the AI heuristic result that APINA just + // computed. If the cache is null (neither APINA nor highlights + // was enabled during chooseSpellAbilityToPlay), compute now as + // a fallback. + Set actionable = cachedActionableCards; + if (actionable == null) { + actionable = AvailableActions.collectActionable(getPlayer(), computeAvailableActionsBudgetMs(getPlayer())); + } + getGui().setWeaklySelectable(actionable); + } + + private boolean cardHasPlayableManaAbility(Card c) { + for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) { + if (sa.isManaAbility() && sa.canPlay()) return true; + } + return false; + } + + public void clearActionableCards() { + getGui().clearWeaklySelectable(); + } + + /** + * Push the set of creatures the player could legally declare as attackers + * right now. Called from {@link forge.gamemodes.match.input.InputAttack} + * when attack declaration begins — and after every click inside the + * attack input — so the blue-outline highlights reflect valid + * attacker candidates that haven't been declared yet. + * + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}; clears the + * selection when the pref is off. + */ + public void pushAttackerCandidates(final Player attackingPlayer, + final forge.game.combat.Combat combat) { + if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + final Set result = Sets.newHashSet(); + for (final Card c : attackingPlayer.getCreaturesInPlay()) { + if (!forge.game.combat.CombatUtil.canAttack(c)) continue; + // Skip creatures already declared as attackers — they can't + // attack again this turn. Mirrors the blocker side, where + // canBlock(blocker, combat) drops blockers that have hit + // their per-turn block-count limit. + if (combat != null && combat.isAttacking(c)) continue; + result.add(c.getView()); + } + getGui().setWeaklySelectable(result); + } + + /** + * Push the set of creatures the player could legally declare as blockers + * right now. Called from {@link forge.gamemodes.match.input.InputBlock} + * when block declaration begins. The candidates are the defending + * player's untapped creatures that could block at least one attacker. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. + */ + public void pushBlockerCandidates(final Player defendingPlayer, + final forge.game.combat.Combat combat) { + if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + final Set result = Sets.newHashSet(); + if (combat == null) { + getGui().setWeaklySelectable(result); + return; + } + final Iterable attackers = combat.getAttackers(); + for (final Card blocker : defendingPlayer.getCreaturesInPlay()) { + if (!forge.game.combat.CombatUtil.canBlock(blocker)) continue; + // Only highlight if the blocker can block at least one live attacker. + boolean canBlockSomething = false; + for (final Card atk : attackers) { + if (forge.game.combat.CombatUtil.canBlock(atk, blocker, combat)) { + canBlockSomething = true; + break; + } + } + if (canBlockSomething) { + result.add(blocker.getView()); + } + } + getGui().setWeaklySelectable(result); + } + @Override public List chooseSpellAbilityToPlay() { netLog.trace("ENTRY for player {}, phase={}, isGameOver={}", @@ -1564,10 +1691,22 @@ public List chooseSpellAbilityToPlay() { final MagicStack stack = getGame().getStack(); // 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()) { + // Compute the actionable set when APINA / suggestions / highlights need it. + boolean highlightsEnabled = FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); + if (!yieldController.isYieldActive() && (needsAvailableActions() || highlightsEnabled)) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); - getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + if (highlightsEnabled) { + // Highlights need the full set; APINA derives its boolean from the same scan. + Set actionable = AvailableActions.collectActionable(getPlayer(), timeoutMs); + cachedActionableCards = actionable; + getPlayer().getView().setHasAvailableActions(!actionable.isEmpty()); + } else { + // APINA only — early-exit boolean scan is cheaper than a full walk. + cachedActionableCards = null; + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + } + } else { + cachedActionableCards = null; } // yieldJustEndedFlag is read from the EDT (didYieldJustEnd); synchronized writer/reader pair handles visibility. From dde284c9eb499a6afa317771f1a9f7a0445a81ee Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Fri, 15 May 2026 13:27:22 -0700 Subject: [PATCH 2/7] Allow customizing the actionable highlight color Adds a hex RGB input on both desktop and mobile preference pages that controls the outline color used for the actionable-card highlight. Defaults to 66CCFF (the existing light blue). - ForgePreferences: new UI_ACTIONABLE_HIGHLIGHT_COLOR pref (default "66CCFF"). - Desktop VSubmenuPreferences/CSubmenuPreferences: text field under the highlight toggle. Accepts a case-insensitive 6-char RGB hex and stores the uppercase form; invalid input reverts the field to the persisted value rather than persisting garbage. - Mobile SettingsPage: new HexColorSetting opens an input dialog beside the highlight toggle. Same normalization on save. - CardPanel (desktop) / CardRenderer (mobile): parse the stored 6-char value at draw time; fall back to default on any parse failure. Validation happens once on the write side so reads don't repeat it. --- .../home/settings/CSubmenuPreferences.java | 33 ++++++++++++ .../home/settings/VSubmenuPreferences.java | 18 +++++++ .../java/forge/view/arcane/CardPanel.java | 17 ++++++- .../src/forge/card/CardRenderer.java | 20 +++++++- .../forge/screens/settings/SettingsPage.java | 50 +++++++++++++++++++ forge-gui/res/languages/en-US.properties | 2 + .../properties/ForgePreferences.java | 1 + 7 files changed, 137 insertions(+), 4 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 24e43a41b28..a853d9c86cb 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -226,10 +226,42 @@ public void initialize() { initializeServerPortButton(); initializeAfkTimeoutButton(); initializeDefaultLanguageComboBox(); + initializeActionableHighlightColorField(); disableLazyLoading(); } + private void initializeActionableHighlightColorField() { + final forge.toolbox.FTextField field = view.getTxtActionableHighlightColor(); + field.setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); + field.addFocusListener(new java.awt.event.FocusAdapter() { + @Override public void focusLost(java.awt.event.FocusEvent e) { saveActionableHighlightColor(field); } + }); + field.addActionListener(e -> saveActionableHighlightColor(field)); + } + + private void saveActionableHighlightColor(forge.toolbox.FTextField field) { + if (updating) return; + String normalized = normalizeHexColor(field.getText()); + if (normalized == null) { + // Invalid input: revert the field to the persisted value rather than silently keeping garbage. + field.setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); + return; + } + field.setText(normalized); + prefs.setPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR, normalized); + prefs.save(); + } + + /** Accepts a case-insensitive 6-char RGB hex; returns it uppercased, or + * null when input isn't 6 hex characters. */ + private static String normalizeHexColor(String raw) { + if (raw == null) return null; + String s = raw.trim(); + if (s.length() != 6 || !s.matches("[0-9A-Fa-f]{6}")) return null; + return s.toUpperCase(); + } + /* (non-Javadoc) * @see forge.gui.control.home.IControlSubmenu#update() */ @@ -247,6 +279,7 @@ public void update() { for(final Pair kv: lstControls) { kv.getKey().setSelected(prefs.getPrefBoolean(kv.getValue())); } + view.getTxtActionableHighlightColor().setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); view.reloadShortcuts(); SwingUtilities.invokeLater(() -> view.getCbRemoveSmall().requestFocusInWindow()); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index b12b669efb5..dcabe7e218f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -91,6 +91,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); private final JCheckBox cbRenderBlackCardBorders = new OptionsCheckBox(localizer.getMessage("cbRenderBlackCardBorders")); private final JCheckBox cbShowActionableHighlights = new OptionsCheckBox(localizer.getMessage("cbShowActionableHighlights")); + private final FTextField txtActionableHighlightColor = new FTextField.Builder().ghostText("66CCFF").maxLength(8).build(); private final JCheckBox cbLargeCardViewers = new OptionsCheckBox(localizer.getMessage("cbLargeCardViewers")); private final JCheckBox cbSmallDeckViewer = new OptionsCheckBox(localizer.getMessage("cbSmallDeckViewer")); private final JCheckBox cbDisplayFoil = new OptionsCheckBox(localizer.getMessage("cbDisplayFoil")); @@ -411,6 +412,9 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbShowActionableHighlights, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlShowActionableHighlights")), descriptionConstraints); + pnlPrefs.add(getActionableHighlightColorPanel(), titleConstraints + ", h 26px!"); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlActionableHighlightColor")), descriptionConstraints); + pnlPrefs.add(cbLargeCardViewers, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlLargeCardViewers")), descriptionConstraints); @@ -824,6 +828,11 @@ public JCheckBox getCbShowActionableHighlights() { return cbShowActionableHighlights; } + /** @return text field holding the hex color for actionable highlights */ + public FTextField getTxtActionableHighlightColor() { + return txtActionableHighlightColor; + } + /** @return {@link javax.swing.JCheckBox} */ public JCheckBox getCbLargeCardViewers() { return cbLargeCardViewers; @@ -1150,6 +1159,15 @@ private JPanel getPlayerNamePanel() { return p; } + private JPanel getActionableHighlightColorPanel() { + JPanel p = new JPanel(new MigLayout("insets 0, gap 0!")); + p.setOpaque(false); + FLabel lbl = new FLabel.Builder().text(localizer.getMessage("lblActionableHighlightColor") + ": ").fontSize(12).fontStyle(Font.BOLD).build(); + p.add(lbl, "aligny top, h 100%, gap 4px 0 0 0"); + p.add(txtActionableHighlightColor, "aligny top, h 100%, w 120px!"); + return p; + } + private JPanel getServerPortPanel() { JPanel p = new JPanel(new MigLayout("insets 0, gap 0!")); p.setOpaque(false); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index bee3da6db9a..0ac7aa2de06 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -380,13 +380,26 @@ protected final void paintComponent(final Graphics g) { final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); } else if (isPreferenceEnabled(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && matchUI.isWeaklySelectable(getCard())) { - // Light blue soft outline for cards the player can currently act on. - g2d.setColor(new Color(0x66, 0xCC, 0xFF)); + // User-configurable RGB hex outline for cards the player can currently act on; defaults to 66CCFF. + g2d.setColor(parseActionableHighlightColor()); final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); } } + private static final Color DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR = new Color(0x66, 0xCC, 0xFF); + + /** Pref is normalized to 6 hex chars on the write side; this just parses. */ + private static Color parseActionableHighlightColor() { + String s = forge.model.FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); + if (s == null || s.length() != 6) return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; + try { + return new Color(Integer.parseInt(s, 16)); + } catch (NumberFormatException e) { + return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; + } + } + private void drawManaCost(final Graphics g, final ManaCost cost, final int deltaY) { final int width = CardFaceSymbols.getWidth(cost); final int height = CardFaceSymbols.getHeight(); diff --git a/forge-gui-mobile/src/forge/card/CardRenderer.java b/forge-gui-mobile/src/forge/card/CardRenderer.java index b8aa50b13d9..b066ae15bc6 100644 --- a/forge-gui-mobile/src/forge/card/CardRenderer.java +++ b/forge-gui-mobile/src/forge/card/CardRenderer.java @@ -68,6 +68,22 @@ public enum CardStackPosition { BehindVert } + private static final Color DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR = FSkinColor.fromRGB(0x66, 0xCC, 0xFF); + + /** Pref is normalized to 6 hex chars on the write side; this just parses. */ + private static Color parseActionableHighlightColor() { + String s = FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); + if (s == null || s.length() != 6) return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; + try { + int r = Integer.parseInt(s.substring(0, 2), 16); + int gr = Integer.parseInt(s.substring(2, 4), 16); + int b = Integer.parseInt(s.substring(4, 6), 16); + return FSkinColor.fromRGB(r, gr, b); + } catch (NumberFormatException e) { + return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; + } + } + // class that simplifies the callback logic of CachedCardImage static class RendererCachedCardImage extends CachedCardImage { boolean clearcardArtCache = false; @@ -817,8 +833,8 @@ public static void drawCardWithOverlays(Graphics g, CardView card, float x, floa g.drawRect(BORDER_THICKNESS, Color.MAGENTA, cx, cy, cw, ch); } else if (!unselectable && FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && MatchController.instance.isWeaklySelectable(card)) { - // Light blue soft outline for cards the player can currently act on. - g.drawRect(BORDER_THICKNESS, FSkinColor.fromRGB(0x66, 0xCC, 0xFF), cx, cy, cw, ch); + // User-configurable RGB hex outline for cards the player can currently act on; defaults to 66CCFF. + g.drawRect(BORDER_THICKNESS, parseActionableHighlightColor(), cx, cy, cw, ch); } //Ability Icons if (unselectable) { diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index 8ee2bdf3657..e883457dddc 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -257,6 +257,10 @@ public void valueChanged(String newValue) { lstSettings.addItem(new BooleanSetting(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS, Forge.getLocalizer().getMessage("cbShowActionableHighlights"), Forge.getLocalizer().getMessage("nlShowActionableHighlights")), 1); + lstSettings.addItem(new HexColorSetting(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR, + Forge.getLocalizer().getMessage("lblActionableHighlightColor"), + Forge.getLocalizer().getMessage("nlActionableHighlightColor"), + "66CCFF"), 1); lstSettings.addItem(new CustomSelectSetting(FPref.UI_ALLOW_ORDER_GRAVEYARD_WHEN_NEEDED, Forge.getLocalizer().getMessage("lblOrderGraveyard"), Forge.getLocalizer().getMessage("nlOrderGraveyard"), @@ -1038,6 +1042,52 @@ public void drawPrefValue(Graphics g, FSkinFont font, FSkinColor color, float x, } } + /** Text input that accepts an RGB hex with optional # or 0x prefix and persists the 6-char uppercase form. */ + private class HexColorSetting extends Setting { + private final String defaultValue; + + public HexColorSetting(FPref pref0, String label0, String description0, String defaultValue0) { + super(pref0, label0 + ":", description0); + this.defaultValue = defaultValue0; + } + + @Override + public void select() { + String currentValue = FModel.getPreferences().getPref((FPref) pref); + if (currentValue == null || currentValue.isEmpty()) currentValue = defaultValue; + FOptionPane.showInputDialog( + label, + description, + currentValue, + null, + input -> { + if (input == null) return; + String normalized = normalizeHexColor(input); + if (normalized == null) { + FOptionPane.showMessageDialog("Please enter a 6-digit RGB hex (e.g. 66CCFF).", "Invalid Color"); + return; + } + FModel.getPreferences().setPref((FPref) pref, normalized); + FModel.getPreferences().save(); + }, + false + ); + } + + @Override + public void drawPrefValue(Graphics g, FSkinFont font, FSkinColor color, float x, float y, float w, float h) { + String value = FModel.getPreferences().getPref((FPref) pref); + g.drawText(value, font, color, x, y, w, h, false, Align.right, false); + } + + private String normalizeHexColor(String raw) { + if (raw == null) return null; + String s = raw.trim(); + if (s.length() != 6 || !s.matches("[0-9A-Fa-f]{6}")) return null; + return s.toUpperCase(); + } + } + private class CustomLogCategoriesDialog extends FDialog { private final FScrollPane scroller; diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 4e22c832b29..9d468ddab9e 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -109,6 +109,7 @@ cbCloneImgSource=Clones Use Original Card Art cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders cbShowActionableHighlights=Highlight Actionable Cards +lblActionableHighlightColor=Highlight Color (hex) cbLargeCardViewers=Use Large Card Viewers cbSmallDeckViewer=Use Small Deck Viewer cbDisplayFoil=Display Foil Overlay @@ -243,6 +244,7 @@ nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. nlRenderBlackCardBorders=Render black borders around card images. nlShowActionableHighlights=Show a light blue outline on cards you can currently play or activate. +nlActionableHighlightColor=6-character RGB hex code for the highlight outline (case insensitive). nlLargeCardViewers=Makes all card viewers much larger for use with high resolution images. Will not fit on smaller screens. nlSmallDeckViewer=Sets the deck viewer window to be 800x600 rather than a proportion of the screen size. nlRandomArtInPools=Generates cards with random art in generated limited mode card pools. 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 82f4b92b2a9..2c34852d9a0 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -110,6 +110,7 @@ public enum FPref implements PreferencesStore.IPref { UI_SCALE_LARGER ("true"), UI_RENDER_BLACK_BORDERS ("true"), UI_SHOW_ACTIONABLE_HIGHLIGHTS ("false"), + UI_ACTIONABLE_HIGHLIGHT_COLOR ("66CCFF"), UI_LARGE_CARD_VIEWERS ("false"), UI_RANDOM_ART_IN_POOLS ("true"), UI_COMPACT_PROMPT ("false"), From 7a7aa5c52aaa6cf491ab4096a995b1f123b5fa75 Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Fri, 15 May 2026 14:07:14 -0700 Subject: [PATCH 3/7] Trim oversized comments in actionable-highlights --- .../main/java/forge/ai/AvailableActions.java | 17 ++----- .../java/forge/view/arcane/CardPanel.java | 1 - .../src/forge/card/CardRenderer.java | 1 - .../forge/screens/settings/SettingsPage.java | 2 +- .../gamemodes/match/input/InputAttack.java | 10 ++-- .../gamemodes/match/input/InputBlock.java | 2 +- .../forge/player/PlayerControllerHuman.java | 51 ++++--------------- 7 files changed, 19 insertions(+), 65 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index 16ec639c55c..393bf2cd7c4 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -65,17 +65,8 @@ private static boolean scan(Player player, long deadlineNanos, long timeoutMs) { return false; } - /** - * Full-scan variant of {@link #compute} that returns the set of card - * views with at least one actionable SA. No early-exit: we walk - * every card so downstream highlight code can reuse the same - * per-card answers the APINA boolean is derived from. - * - * Timeout behavior: on expiry, remaining unvisited cards are added - * to the set (FP-safe — the player is shown extra highlights rather - * than missing some). The APINA boolean can be derived as - * {@code !result.isEmpty()}. - */ + /** Full-scan sibling of {@link #compute}. On timeout, remaining cards are + * marked actionable so the user sees extra highlights rather than missing some. */ public static Set collectActionable(Player player, long timeoutMs) { long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; Set actionable = new HashSet<>(); @@ -133,9 +124,7 @@ private static boolean cardHasActionableActivated(Card card, Player player) { return false; } - /** FP-safe timeout fallback: mark every card in a scannable zone as - * actionable. The player sees extra highlights rather than missing - * playable cards. */ + /** Timeout fallback: mark every scannable card so the user doesn't miss highlights. */ private static void addAllRemaining(Set actionable, Player player) { for (Card c : player.getCardsIn(ZoneType.Hand)) actionable.add(c.getView()); for (Card c : player.getCardsIn(ZoneType.Battlefield)) actionable.add(c.getView()); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index 0ac7aa2de06..365ee27ef25 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -380,7 +380,6 @@ protected final void paintComponent(final Graphics g) { final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); } else if (isPreferenceEnabled(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && matchUI.isWeaklySelectable(getCard())) { - // User-configurable RGB hex outline for cards the player can currently act on; defaults to 66CCFF. g2d.setColor(parseActionableHighlightColor()); final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); diff --git a/forge-gui-mobile/src/forge/card/CardRenderer.java b/forge-gui-mobile/src/forge/card/CardRenderer.java index b066ae15bc6..73f9a5278d8 100644 --- a/forge-gui-mobile/src/forge/card/CardRenderer.java +++ b/forge-gui-mobile/src/forge/card/CardRenderer.java @@ -833,7 +833,6 @@ public static void drawCardWithOverlays(Graphics g, CardView card, float x, floa g.drawRect(BORDER_THICKNESS, Color.MAGENTA, cx, cy, cw, ch); } else if (!unselectable && FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && MatchController.instance.isWeaklySelectable(card)) { - // User-configurable RGB hex outline for cards the player can currently act on; defaults to 66CCFF. g.drawRect(BORDER_THICKNESS, parseActionableHighlightColor(), cx, cy, cw, ch); } //Ability Icons diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index e883457dddc..6f388a2e3c5 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -1042,7 +1042,7 @@ public void drawPrefValue(Graphics g, FSkinFont font, FSkinColor color, float x, } } - /** Text input that accepts an RGB hex with optional # or 0x prefix and persists the 6-char uppercase form. */ + /** Text input that accepts a 6-char RGB hex and persists the uppercase form. */ private class HexColorSetting extends Setting { private final String defaultValue; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java index 68cbb4cf438..c81c1cd12e2 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java @@ -98,7 +98,7 @@ private void disablePrompt() { @Override protected void onStop() { - // Highlights pushed in showMessage would otherwise persist on remote clients through autopass. + // Clear so highlights don't survive autopass. getController().clearActionableCards(); } @@ -352,12 +352,8 @@ private void updateMessage() { updatePrompt(); - // Refresh the actionable-card highlight set after every click so - // declared attackers stop glowing (and call-backs restore the - // glow). Mirrors InputBlock's behavior, where its no-arg - // showMessage() override re-pushes blocker candidates after each - // selection. We can't call our own no-arg showMessage() here - // because it also resets currentDefender. + // Re-push after each click so declared attackers stop glowing. + // Can't reuse showMessage() — it also resets currentDefender. getController().pushAttackerCandidates(playerAttacks, combat); if (combat != null) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java index 2ed9a05a98c..c06f8d47c18 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java @@ -92,7 +92,7 @@ protected final void showMessage() { @Override protected void onStop() { - // Highlights pushed in showMessage would otherwise persist on remote clients through autopass. + // Clear so highlights don't survive autopass. getController().clearActionableCards(); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index f9a5584f81f..4a276d03e7b 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1561,23 +1561,13 @@ public void declareBlockers(final Player defender, final Combat combat) { ZoneType.Hand, ZoneType.Battlefield, ZoneType.Graveyard, ZoneType.Exile, ZoneType.Command }; - /** Actionable card set cached from the last {@link AvailableActions#collectActionable} - * call inside {@link #chooseSpellAbilityToPlay}. {@code pushActionableCards} - * reads this in non-payment mode so the highlights and the APINA - * boolean come from the same heuristic pass — no second scan. */ + /** Cache from {@link AvailableActions#collectActionable}, populated in + * {@link #chooseSpellAbilityToPlay} and reused by {@link #pushActionableCards}. */ private Set cachedActionableCards; - /** - * Push the set of actionable cards to the GUI. In non-payment mode, - * reuses the set that {@link AvailableActions#collectActionable} - * populated during the APINA pass — same AI heuristic, no rerun. - * In payment mode (inside a cost payment), the AI check is not - * meaningful (you're already paying); we fall back to the simpler - * "card has a playable mana ability" predicate. - * - * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS} — when off, - * the highlight set is cleared. - */ + /** Push the actionable-card set to the GUI. Payment mode falls back to the + * "playable mana ability" predicate; non-payment reuses {@link #cachedActionableCards}. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushActionableCards(boolean paymentMode) { if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { getGui().clearWeaklySelectable(); @@ -1597,10 +1587,7 @@ public void pushActionableCards(boolean paymentMode) { return; } - // Normal priority: reuse the AI heuristic result that APINA just - // computed. If the cache is null (neither APINA nor highlights - // was enabled during chooseSpellAbilityToPlay), compute now as - // a fallback. + // Reuse the priority-time scan; recompute if neither APINA nor highlights triggered it. Set actionable = cachedActionableCards; if (actionable == null) { actionable = AvailableActions.collectActionable(getPlayer(), computeAvailableActionsBudgetMs(getPlayer())); @@ -1619,16 +1606,8 @@ public void clearActionableCards() { getGui().clearWeaklySelectable(); } - /** - * Push the set of creatures the player could legally declare as attackers - * right now. Called from {@link forge.gamemodes.match.input.InputAttack} - * when attack declaration begins — and after every click inside the - * attack input — so the blue-outline highlights reflect valid - * attacker candidates that haven't been declared yet. - * - * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}; clears the - * selection when the pref is off. - */ + /** Push undeclared, legal attacker candidates. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushAttackerCandidates(final Player attackingPlayer, final forge.game.combat.Combat combat) { if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { @@ -1638,23 +1617,15 @@ public void pushAttackerCandidates(final Player attackingPlayer, final Set result = Sets.newHashSet(); for (final Card c : attackingPlayer.getCreaturesInPlay()) { if (!forge.game.combat.CombatUtil.canAttack(c)) continue; - // Skip creatures already declared as attackers — they can't - // attack again this turn. Mirrors the blocker side, where - // canBlock(blocker, combat) drops blockers that have hit - // their per-turn block-count limit. + // Drop already-declared attackers. if (combat != null && combat.isAttacking(c)) continue; result.add(c.getView()); } getGui().setWeaklySelectable(result); } - /** - * Push the set of creatures the player could legally declare as blockers - * right now. Called from {@link forge.gamemodes.match.input.InputBlock} - * when block declaration begins. The candidates are the defending - * player's untapped creatures that could block at least one attacker. - * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. - */ + /** Push defenders that could block at least one current attacker. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushBlockerCandidates(final Player defendingPlayer, final forge.game.combat.Combat combat) { if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { From 65c9f2f6eda202f800259d5dffe1c23d65ffd4a4 Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Mon, 18 May 2026 12:29:13 -0700 Subject: [PATCH 4/7] Compress looping, update hex color character limit --- .../main/java/forge/ai/AvailableActions.java | 131 ++++++++---------- .../home/settings/VSubmenuPreferences.java | 2 +- 2 files changed, 56 insertions(+), 77 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index 393bf2cd7c4..dbfd03a2cee 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -9,93 +9,68 @@ import org.tinylog.Logger; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiPredicate; +import java.util.function.Supplier; 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). +// Bounded by timeoutMs; FP-safe on expiry — unvisited cards are marked actionable. public final class AvailableActions { private AvailableActions() {} - public static boolean compute(Player player, long timeoutMs) { - long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; - - // Run the predictive sweep under an AI controller so cost-adjustment chooseX dispatches don't prompt (mirrors InputPayMana auto-pay). - boolean[] result = {false}; - player.runWithController( - () -> result[0] = scan(player, deadlineNanos, timeoutMs), - new PlayerControllerAi(player.getGame(), player, player.getOriginalLobbyPlayer())); - return result[0]; - } + /** Hand spells and lands; activated abilities on the battlefield; flashback spells. */ + private record ZoneScan(ZoneType zone, boolean sortByCmc, BiPredicate hasActionable) {} - private static boolean scan(Player player, long deadlineNanos, long timeoutMs) { - 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; - } - } - } + private static final List SCANS = List.of( + new ZoneScan(ZoneType.Hand, true, AvailableActions::cardHasActionableSpell), + // Battlefield isn't sorted: activation costs are per-ability, not the permanent's CMC. + new ZoneScan(ZoneType.Battlefield, false, AvailableActions::cardHasActionableActivated), + new ZoneScan(ZoneType.Flashback, true, AvailableActions::cardHasActionableActivated)); - // 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; - } - } - } + /** Boolean form: early-exits on the first actionable card. */ + public static boolean compute(Player player, long timeoutMs) { + return withAiController(player, () -> !walk(player, timeoutMs, true).isEmpty()); + } - 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; - } - } - } + /** Set form: walks every card so highlight consumers can mark the actionable subset. */ + public static Set collectActionable(Player player, long timeoutMs) { + return withAiController(player, () -> walk(player, timeoutMs, false)); + } - return false; + /** Run the predictive sweep under an AI controller so cost-adjustment chooseX + * dispatches don't prompt (mirrors InputPayMana auto-pay). */ + private static T withAiController(Player player, Supplier body) { + AtomicReference result = new AtomicReference<>(); + player.runWithController( + () -> result.set(body.get()), + new PlayerControllerAi(player.getGame(), player, player.getOriginalLobbyPlayer())); + return result.get(); } - /** Full-scan sibling of {@link #compute}. On timeout, remaining cards are - * marked actionable so the user sees extra highlights rather than missing some. */ - public static Set collectActionable(Player player, long timeoutMs) { + private static Set walk(Player player, long timeoutMs, boolean earlyExit) { long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; Set actionable = new HashSet<>(); - - for (Card card : sortedCardsIn(player, ZoneType.Hand)) { - if (checkTimeout(deadlineNanos, timeoutMs)) { - addAllRemaining(actionable, player); - return actionable; - } - if (cardHasActionableSpell(card, player)) { - actionable.add(card.getView()); - } - } - for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - if (checkTimeout(deadlineNanos, timeoutMs)) { - addAllRemaining(actionable, player); - return actionable; - } - if (cardHasActionableActivated(card, player)) { - actionable.add(card.getView()); - } - } - for (Card card : sortedCardsIn(player, ZoneType.Flashback)) { - if (checkTimeout(deadlineNanos, timeoutMs)) { - addAllRemaining(actionable, player); - return actionable; - } - if (cardHasActionableActivated(card, player)) { - actionable.add(card.getView()); + Set visited = new HashSet<>(); + + for (ZoneScan scan : SCANS) { + Iterable cards = scan.sortByCmc() + ? sortedCardsIn(player, scan.zone()) + : player.getCardsIn(scan.zone()); + for (Card card : cards) { + if (checkTimeout(deadlineNanos, timeoutMs)) { + addUnvisited(actionable, visited, player); + return actionable; + } + CardView cv = card.getView(); + visited.add(cv); + if (scan.hasActionable().test(card, player)) { + actionable.add(cv); + if (earlyExit) return actionable; + } } } return actionable; @@ -124,11 +99,15 @@ private static boolean cardHasActionableActivated(Card card, Player player) { return false; } - /** Timeout fallback: mark every scannable card so the user doesn't miss highlights. */ - private static void addAllRemaining(Set actionable, Player player) { - for (Card c : player.getCardsIn(ZoneType.Hand)) actionable.add(c.getView()); - for (Card c : player.getCardsIn(ZoneType.Battlefield)) actionable.add(c.getView()); - for (Card c : player.getCardsIn(ZoneType.Flashback)) actionable.add(c.getView()); + /** Timeout fallback: mark only the cards we never got to evaluate (FP-safe). + * Cards we visited and ruled out keep their determination. */ + private static void addUnvisited(Set actionable, Set visited, Player player) { + for (ZoneScan scan : SCANS) { + for (Card c : player.getCardsIn(scan.zone())) { + CardView cv = c.getView(); + if (!visited.contains(cv)) actionable.add(cv); + } + } } // Sort cheap cards first so cheap-to-validate matches early-exit diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index dcabe7e218f..270144503bd 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -91,7 +91,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); private final JCheckBox cbRenderBlackCardBorders = new OptionsCheckBox(localizer.getMessage("cbRenderBlackCardBorders")); private final JCheckBox cbShowActionableHighlights = new OptionsCheckBox(localizer.getMessage("cbShowActionableHighlights")); - private final FTextField txtActionableHighlightColor = new FTextField.Builder().ghostText("66CCFF").maxLength(8).build(); + private final FTextField txtActionableHighlightColor = new FTextField.Builder().ghostText("66CCFF").maxLength(6).build(); private final JCheckBox cbLargeCardViewers = new OptionsCheckBox(localizer.getMessage("cbLargeCardViewers")); private final JCheckBox cbSmallDeckViewer = new OptionsCheckBox(localizer.getMessage("cbSmallDeckViewer")); private final JCheckBox cbDisplayFoil = new OptionsCheckBox(localizer.getMessage("cbDisplayFoil")); From 055a6d9de5ad7e44669676574050d3bec56292d1 Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Tue, 19 May 2026 12:08:08 -0700 Subject: [PATCH 5/7] AvailableActions ignored activated abilities on cards in hand (Channel, forecast, etc.), affected both highlights and APINA --- .../main/java/forge/ai/AvailableActions.java | 32 ++++++------------- .../java/forge/view/arcane/CardPanel.java | 13 +++----- .../src/forge/card/CardRenderer.java | 23 ++++++------- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index dbfd03a2cee..b04d2bfab71 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -12,7 +12,6 @@ import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiPredicate; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -22,14 +21,14 @@ public final class AvailableActions { private AvailableActions() {} - /** Hand spells and lands; activated abilities on the battlefield; flashback spells. */ - private record ZoneScan(ZoneType zone, boolean sortByCmc, BiPredicate hasActionable) {} + /** sortByCmc: cheap-cost-first so early-exit hits faster. + * Battlefield isn't sorted: activation costs are per-ability, not the permanent's CMC. */ + private record ZoneScan(ZoneType zone, boolean sortByCmc) {} private static final List SCANS = List.of( - new ZoneScan(ZoneType.Hand, true, AvailableActions::cardHasActionableSpell), - // Battlefield isn't sorted: activation costs are per-ability, not the permanent's CMC. - new ZoneScan(ZoneType.Battlefield, false, AvailableActions::cardHasActionableActivated), - new ZoneScan(ZoneType.Flashback, true, AvailableActions::cardHasActionableActivated)); + new ZoneScan(ZoneType.Hand, true), + new ZoneScan(ZoneType.Battlefield, false), + new ZoneScan(ZoneType.Flashback, true)); /** Boolean form: early-exits on the first actionable card. */ public static boolean compute(Player player, long timeoutMs) { @@ -67,7 +66,7 @@ private static Set walk(Player player, long timeoutMs, boolean earlyEx } CardView cv = card.getView(); visited.add(cv); - if (scan.hasActionable().test(card, player)) { + if (cardHasActionable(card, player)) { actionable.add(cv); if (earlyExit) return actionable; } @@ -76,20 +75,9 @@ private static Set walk(Player player, long timeoutMs, boolean earlyEx return actionable; } - private static boolean cardHasActionableSpell(Card card, Player player) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (sa.isSpell()) { - if (canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { - return true; - } - } else if (sa.isLandAbility()) { - return true; - } - } - return false; - } - - private static boolean cardHasActionableActivated(Card card, Player player) { + // Land plays come through with no cost, no targets; spells and activated abilities (incl. + // hand-activations like Channel) all hit the same shape: not a mana ability, affordable, targetable. + private static boolean cardHasActionable(Card card, Player player) { for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index 365ee27ef25..116ad4fb6f2 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -386,17 +386,14 @@ protected final void paintComponent(final Graphics g) { } } - private static final Color DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR = new Color(0x66, 0xCC, 0xFF); - - /** Pref is normalized to 6 hex chars on the write side; this just parses. */ + /** Pref is normalized to 6 hex chars on the write side; this just parses, + * falling back to the FPref default if the stored value is malformed. */ private static Color parseActionableHighlightColor() { String s = forge.model.FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); - if (s == null || s.length() != 6) return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; try { - return new Color(Integer.parseInt(s, 16)); - } catch (NumberFormatException e) { - return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; - } + if (s != null && s.length() == 6) return new Color(Integer.parseInt(s, 16)); + } catch (NumberFormatException ignored) {} + return new Color(Integer.parseInt(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR.getDefault(), 16)); } private void drawManaCost(final Graphics g, final ManaCost cost, final int deltaY) { diff --git a/forge-gui-mobile/src/forge/card/CardRenderer.java b/forge-gui-mobile/src/forge/card/CardRenderer.java index 73f9a5278d8..a30b78b2f0a 100644 --- a/forge-gui-mobile/src/forge/card/CardRenderer.java +++ b/forge-gui-mobile/src/forge/card/CardRenderer.java @@ -68,20 +68,21 @@ public enum CardStackPosition { BehindVert } - private static final Color DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR = FSkinColor.fromRGB(0x66, 0xCC, 0xFF); - - /** Pref is normalized to 6 hex chars on the write side; this just parses. */ + /** Pref is normalized to 6 hex chars on the write side; this just parses, + * falling back to the FPref default if the stored value is malformed. */ private static Color parseActionableHighlightColor() { String s = FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); - if (s == null || s.length() != 6) return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; try { - int r = Integer.parseInt(s.substring(0, 2), 16); - int gr = Integer.parseInt(s.substring(2, 4), 16); - int b = Integer.parseInt(s.substring(4, 6), 16); - return FSkinColor.fromRGB(r, gr, b); - } catch (NumberFormatException e) { - return DEFAULT_ACTIONABLE_HIGHLIGHT_COLOR; - } + if (s != null && s.length() == 6) return rgbFromHex(s); + } catch (NumberFormatException ignored) {} + return rgbFromHex(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR.getDefault()); + } + + private static Color rgbFromHex(String s) { + int r = Integer.parseInt(s.substring(0, 2), 16); + int g = Integer.parseInt(s.substring(2, 4), 16); + int b = Integer.parseInt(s.substring(4, 6), 16); + return FSkinColor.fromRGB(r, g, b); } // class that simplifies the callback logic of CachedCardImage From fe4a5b3119c95f1e8734ba4e9f73c5578dee4a03 Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Tue, 19 May 2026 19:51:08 -0700 Subject: [PATCH 6/7] Remove the word blue from highlight text --- forge-gui/res/languages/en-US.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 9d468ddab9e..3b69d88e415 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -243,7 +243,7 @@ nlDisplayFoil=Displays foil cards with the visual foil overlay effect. nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. nlRenderBlackCardBorders=Render black borders around card images. -nlShowActionableHighlights=Show a light blue outline on cards you can currently play or activate. +nlShowActionableHighlights=Show an outline on cards you can currently play or activate. nlActionableHighlightColor=6-character RGB hex code for the highlight outline (case insensitive). nlLargeCardViewers=Makes all card viewers much larger for use with high resolution images. Will not fit on smaller screens. nlSmallDeckViewer=Sets the deck viewer window to be 800x600 rather than a proportion of the screen size. From ec81ee1b4cfa0b36124411cbcc87ece84e158fb5 Mon Sep 17 00:00:00 2001 From: Autumn Wind <209156905+autumnmyst@users.noreply.github.com> Date: Tue, 19 May 2026 20:25:22 -0700 Subject: [PATCH 7/7] Route actionable-highlight scan through the per-player yield pref The host runs the AvailableActions scan on a remote player's behalf, so the decision to do the full collectActionable walk (vs the cheap early-exit) must use that client's highlight setting, not the host's. Thread UI_SHOW_ACTIONABLE_HIGHLIGHTS through the existing client->host pref seed (SYNCED_PREFS) and read it via yieldController.getBoolPref at every scan-gating point in PlayerControllerHuman instead of FModel. Renderer reads stay on FModel since they run in the client process. --- .../main/java/forge/gamemodes/match/YieldController.java | 5 ++++- .../src/main/java/forge/player/PlayerControllerHuman.java | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 5bc222645c7..c6514e14232 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -58,7 +58,10 @@ public class YieldController { FPref.YIELD_SUPPRESS_AFTER_END, FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, - FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, + // Not a yield pref, but seeded the same way: the host runs the actionable scan on + // the remote player's behalf and must use that client's highlight setting, not its own. + FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); private final PlayerControllerHuman owner; diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 4a276d03e7b..9a452fe7ba3 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1569,7 +1569,7 @@ public void declareBlockers(final Player defender, final Combat combat) { * "playable mana ability" predicate; non-payment reuses {@link #cachedActionableCards}. * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushActionableCards(boolean paymentMode) { - if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { getGui().clearWeaklySelectable(); return; } @@ -1610,7 +1610,7 @@ public void clearActionableCards() { * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushAttackerCandidates(final Player attackingPlayer, final forge.game.combat.Combat combat) { - if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { getGui().clearWeaklySelectable(); return; } @@ -1628,7 +1628,7 @@ public void pushAttackerCandidates(final Player attackingPlayer, * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ public void pushBlockerCandidates(final Player defendingPlayer, final forge.game.combat.Combat combat) { - if (!FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { getGui().clearWeaklySelectable(); return; } @@ -1663,7 +1663,7 @@ public List chooseSpellAbilityToPlay() { // Skip when already yielding — yield proceeds regardless of available-actions. // Compute the actionable set when APINA / suggestions / highlights need it. - boolean highlightsEnabled = FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); + boolean highlightsEnabled = yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); if (!yieldController.isYieldActive() && (needsAvailableActions() || highlightsEnabled)) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); if (highlightsEnabled) {