Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 67 additions & 31 deletions forge-ai/src/main/java/forge/ai/AvailableActions.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,100 @@

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.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
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() {}

/** 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<ZoneScan> SCANS = List.of(
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) {
long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L;
return withAiController(player, () -> !walk(player, timeoutMs, true).isEmpty());
}

// Run the predictive sweep under an AI controller so cost-adjustment chooseX dispatches don't prompt (mirrors InputPayMana auto-pay).
boolean[] result = {false};
/** Set form: walks every card so highlight consumers can mark the actionable subset. */
public static Set<CardView> collectActionable(Player player, long timeoutMs) {
return withAiController(player, () -> walk(player, timeoutMs, false));
}

/** Run the predictive sweep under an AI controller so cost-adjustment chooseX
* dispatches don't prompt (mirrors InputPayMana auto-pay). */
private static <T> T withAiController(Player player, Supplier<T> body) {
AtomicReference<T> result = new AtomicReference<>();
player.runWithController(
() -> result[0] = scan(player, deadlineNanos, timeoutMs),
() -> result.set(body.get()),
new PlayerControllerAi(player.getGame(), player, player.getOriginalLobbyPlayer()));
return result[0];
return result.get();
}

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 Set<CardView> walk(Player player, long timeoutMs, boolean earlyExit) {
long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L;
Set<CardView> actionable = new HashSet<>();
Set<CardView> visited = new HashSet<>();

for (ZoneScan scan : SCANS) {
Iterable<Card> 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 (cardHasActionable(card, player)) {
actionable.add(cv);
if (earlyExit) return actionable;
}
}
}
return actionable;
}

// 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;
}
// 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)) {
return true;
}
}
return false;
}

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;
}
/** 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<CardView> actionable, Set<CardView> 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);
}
}

return false;
}

// Sort cheap cards first so cheap-to-validate matches early-exit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -225,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()
*/
Expand All @@ -246,6 +279,7 @@ public void update() {
for(final Pair<JCheckBox, FPref> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public enum VSubmenuPreferences implements IVSubmenu<CSubmenuPreferences> {
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 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"));
Expand Down Expand Up @@ -407,6 +409,12 @@ public enum VSubmenuPreferences implements IVSubmenu<CSubmenuPreferences> {
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(getActionableHighlightColorPanel(), titleConstraints + ", h 26px!");
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlActionableHighlightColor")), descriptionConstraints);

pnlPrefs.add(cbLargeCardViewers, titleConstraints);
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlLargeCardViewers")), descriptionConstraints);

Expand Down Expand Up @@ -815,6 +823,16 @@ public JCheckBox getCbRenderBlackCardBorders() {
return cbRenderBlackCardBorders;
}

/** @return {@link javax.swing.JCheckBox} */
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;
Expand Down Expand Up @@ -1141,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);
Expand Down
32 changes: 32 additions & 0 deletions forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,38 @@ public void setHighlighted(final Iterable<GameEntityView> entities, final boolea
}
}

@Override
public void setWeaklySelectable(final Iterable<CardView> 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();
Expand Down
14 changes: 14 additions & 0 deletions forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,23 @@ 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())) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you really test this...?
I don't actually see the border ingame, maybe something got slightly moved some time ago to make those dimensions you copied from incorrect 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in you don't see the highlights at all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have the preference on, you should see blue borders around the playable cards

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well it works in mobile, maybe you only checked there unless something specific is required for it to not draw correctly?

Copy link
Copy Markdown
Contributor Author

@autumnmyst autumnmyst May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's the last change I made removing the hardcoded default vs preference default. I had changed my color from default so I didn't notice it, but if you never touch the color the default doesn't properly set in preferences. Should be a small fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, actually no it should default just fine. Unless you manually delete the hex color from your preferences, then it won't default, but I think that's intentional to rely on the default not a hardcoded one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why resolve, this isn't really fixed yet?

Copy link
Copy Markdown
Contributor Author

@autumnmyst autumnmyst May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I thought from your discord msg that a render black borders bug was the culprit. If you use default settings do you see this issue? I'm not sure how to reproduce it for myself to debug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh right, I meant the preference with that name - it's actually right above your new one

while it is enabled by default and it's not really your code it would still be better to fix all broken borders in that method as a bonus

g2d.setColor(parseActionableHighlightColor());
final int ins = 1;
g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins);
}
}

/** 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);
try {
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) {
final int width = CardFaceSymbols.getWidth(cost);
final int height = CardFaceSymbols.getHeight();
Expand Down
20 changes: 20 additions & 0 deletions forge-gui-mobile/src/forge/card/CardRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ public enum CardStackPosition {
BehindVert
}

/** 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);
try {
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
static class RendererCachedCardImage extends CachedCardImage {
boolean clearcardArtCache = false;
Expand Down Expand Up @@ -815,6 +832,9 @@ 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)) {
g.drawRect(BORDER_THICKNESS, parseActionableHighlightColor(), cx, cy, cw, ch);
}
//Ability Icons
if (unselectable) {
Expand Down
30 changes: 30 additions & 0 deletions forge-gui-mobile/src/forge/screens/match/MatchController.java
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,36 @@ public void clearSelectables() {
});
}

@Override
public void setWeaklySelectable(final Iterable<CardView> 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();
Expand Down
Loading