From a6be7a9354b063d0e34d522d85e16167ad5ce6c6 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Wed, 10 Jun 2026 08:56:08 -0700 Subject: [PATCH 1/3] Add optional CommanderBracket.app integration for Commander bracket estimates --- forge-core/src/main/java/forge/deck/Deck.java | 16 + .../java/forge/deck/io/DeckFileHeader.java | 27 + .../java/forge/deck/io/DeckSerializer.java | 7 +- .../src/main/java/forge/util/JsonUtil.java | 199 +++++++ .../java/forge/itemmanager/DeckManager.java | 32 ++ .../views/CommanderBracketDeckView.java | 9 +- .../views/CommanderBracketTextView.java | 123 ++++- .../views/CommanderBracketView.java | 23 +- .../home/settings/CSubmenuPreferences.java | 1 + .../home/settings/VSubmenuPreferences.java | 8 + .../forge/screens/match/views/VField.java | 4 +- forge-gui/res/languages/de-DE.properties | 18 + forge-gui/res/languages/en-US.properties | 20 +- forge-gui/res/languages/es-ES.properties | 18 + forge-gui/res/languages/fr-FR.properties | 18 + forge-gui/res/languages/it-IT.properties | 18 + forge-gui/res/languages/ja-JP.properties | 18 + forge-gui/res/languages/ko-KR.properties | 18 + forge-gui/res/languages/pt-BR.properties | 18 + forge-gui/res/languages/zh-CN.properties | 18 + .../forge/deck/CommanderBracketApiClient.java | 289 +++++++++++ .../forge/deck/CommanderBracketService.java | 488 ++++++++++++++++++ .../src/main/java/forge/deck/DeckProxy.java | 14 + .../java/forge/itemmanager/ColumnDef.java | 6 +- .../properties/ForgePreferences.java | 1 + 25 files changed, 1398 insertions(+), 13 deletions(-) create mode 100644 forge-core/src/main/java/forge/util/JsonUtil.java create mode 100644 forge-gui/src/main/java/forge/deck/CommanderBracketApiClient.java create mode 100644 forge-gui/src/main/java/forge/deck/CommanderBracketService.java diff --git a/forge-core/src/main/java/forge/deck/Deck.java b/forge-core/src/main/java/forge/deck/Deck.java index 399d70edeac..505e890493f 100644 --- a/forge-core/src/main/java/forge/deck/Deck.java +++ b/forge-core/src/main/java/forge/deck/Deck.java @@ -53,6 +53,8 @@ public class Deck extends DeckBase implements Iterable aiHints = new TreeSet<>(); private final List keyCards = new ArrayList<>(); private final Map draftNotes = new HashMap<>(); + private String deckHash; + private Integer commanderBracket; private Map> deferredSections = null; private Map> loadedSections = null; private String lastCardArtPreferenceUsed = ""; @@ -253,6 +255,7 @@ protected void cloneFieldsTo(final DeckBase clone) { } result.setAiHints(StringUtils.join(aiHints, " | ")); result.setDraftNotes(draftNotes); + result.setCommanderBracket(deckHash, commanderBracket); //noinspection ConstantValue if(tags != null) //Can happen deserializing old Decks. result.tags.addAll(this.tags); @@ -526,6 +529,19 @@ public Set getTags() { return tags; } + public String getDeckHash() { + return deckHash; + } + + public Integer getCommanderBracket() { + return commanderBracket; + } + + public void setCommanderBracket(final String hash, final Integer bracket) { + deckHash = hash; + commanderBracket = bracket; + } + public CardPool getAllCardsInASinglePool() { return getAllCardsInASinglePool(true, false); } diff --git a/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java b/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java index 8be6dce6dee..25ea698c58c 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java +++ b/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java @@ -43,6 +43,8 @@ public class DeckFileHeader { public static final String TAGS_SEPARATOR = ","; public static final String DRAFT_NOTES = "DraftNotes"; public static final String KEY_CARDS = "KeyCards"; + public static final String DECK_HASH = "DeckHash"; + public static final String COMMANDER_BRACKET = "CommanderBracket"; /** The Constant COMMENT. */ public static final String COMMENT = "Comment"; @@ -60,6 +62,8 @@ public class DeckFileHeader { private final Set tags; private final HashMap draftNotes; private final List keyCards; + private final String deckHash; + private final Integer commanderBracket; private final boolean intendedForAi; private final String aiHints; @@ -79,6 +83,8 @@ public DeckFileHeader(final FileSection kvPairs) { this.customPool = kvPairs.getBoolean(DeckFileHeader.CSTM_POOL); this.intendedForAi = "computer".equalsIgnoreCase(kvPairs.get(DeckFileHeader.PLAYER)) || "ai".equalsIgnoreCase(kvPairs.get(DeckFileHeader.PLAYER_TYPE)); this.aiHints = kvPairs.get(DeckFileHeader.AI_HINTS); + this.deckHash = kvPairs.get(DeckFileHeader.DECK_HASH); + this.commanderBracket = parseCommanderBracket(kvPairs.get(DeckFileHeader.COMMANDER_BRACKET)); this.tags = new TreeSet<>(); @@ -100,6 +106,19 @@ public DeckFileHeader(final FileSection kvPairs) { } } + private static Integer parseCommanderBracket(final String rawBracket) { + if (StringUtils.isBlank(rawBracket)) { + return null; + } + try { + final int bracket = Integer.parseInt(rawBracket.trim()); + return bracket >= 1 && bracket <= 5 ? bracket : null; + } + catch (final NumberFormatException e) { + return null; + } + } + private void extractDraftNotes(String rawNotes) { if(StringUtils.isBlank(rawNotes) ) { return; @@ -147,4 +166,12 @@ public final HashMap getDraftNotes() { public final List getKeyCards() { return keyCards; } + + public final String getDeckHash() { + return deckHash; + } + + public final Integer getCommanderBracket() { + return commanderBracket; + } } diff --git a/forge-core/src/main/java/forge/deck/io/DeckSerializer.java b/forge-core/src/main/java/forge/deck/io/DeckSerializer.java index b591dcde9f6..c7c67f1e98d 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckSerializer.java +++ b/forge-core/src/main/java/forge/deck/io/DeckSerializer.java @@ -49,6 +49,10 @@ private static List serializeDeck(Deck d) { if (d.getComment() != null) { out.add(TextUtil.concatNoSpace(DeckFileHeader.COMMENT,"=", d.getComment().replaceAll("\n", ""))); } + if (StringUtils.isNotBlank(d.getDeckHash()) && d.getCommanderBracket() != null) { + out.add(TextUtil.concatNoSpace(DeckFileHeader.DECK_HASH, "=", d.getDeckHash())); + out.add(TextUtil.concatNoSpace(DeckFileHeader.COMMANDER_BRACKET, "=", String.valueOf(d.getCommanderBracket()))); + } if (!d.getTags().isEmpty()) { out.add(TextUtil.concatNoSpace(DeckFileHeader.TAGS,"=", StringUtils.join(d.getTags(), DeckFileHeader.TAGS_SEPARATOR))); } @@ -103,10 +107,11 @@ public static Deck fromSections(final Map> sections) { d.setAiHints(dh.getAiHints()); d.getTags().addAll(dh.getTags()); d.setDraftNotes(dh.getDraftNotes()); + d.setCommanderBracket(dh.getDeckHash(), dh.getCommanderBracket()); for (String keyCard : dh.getKeyCards()) { d.addKeyCard(keyCard); } d.setDeferredSections(sections); return d; } -} \ No newline at end of file +} diff --git a/forge-core/src/main/java/forge/util/JsonUtil.java b/forge-core/src/main/java/forge/util/JsonUtil.java new file mode 100644 index 00000000000..7293e8789d0 --- /dev/null +++ b/forge-core/src/main/java/forge/util/JsonUtil.java @@ -0,0 +1,199 @@ +package forge.util; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class JsonUtil { + private JsonUtil() { + } + + public static Object parse(final String text) throws IOException { + final Parser parser = new Parser(text); + final Object value = parser.readValue(); + parser.skipWhitespace(); + if (parser.index != parser.text.length()) { + throw new IOException("Trailing JSON data."); + } + return value; + } + + public static String escape(final String value) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + switch (c) { + case '\\' -> sb.append("\\\\"); + case '"' -> sb.append("\\\""); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int)c)); + } + else { + sb.append(c); + } + } + } + } + return sb.toString(); + } + + private static final class Parser { + private final String text; + private int index; + + private Parser(final String text) { + this.text = text; + } + + private Object readValue() throws IOException { + skipWhitespace(); + if (index >= text.length()) { + throw new IOException("Unexpected end of JSON."); + } + return switch (text.charAt(index)) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + case 't' -> readLiteral("true", Boolean.TRUE); + case 'f' -> readLiteral("false", Boolean.FALSE); + case 'n' -> readLiteral("null", null); + default -> readNumber(); + }; + } + + private Map readObject() throws IOException { + index++; + final Map result = new HashMap<>(); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + while (true) { + final String key = readString(); + skipWhitespace(); + expect(':'); + result.put(key, readValue()); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + expect(','); + skipWhitespace(); + } + } + + private List readArray() throws IOException { + index++; + final List result = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + while (true) { + result.add(readValue()); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + expect(','); + } + } + + private String readString() throws IOException { + expect('"'); + final StringBuilder sb = new StringBuilder(); + while (index < text.length()) { + final char c = text.charAt(index++); + if (c == '"') { + return sb.toString(); + } + if (c != '\\') { + sb.append(c); + continue; + } + if (index >= text.length()) { + throw new IOException("Bad JSON escape."); + } + final char escaped = text.charAt(index++); + switch (escaped) { + case '"', '\\', '/' -> sb.append(escaped); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> { + if (index + 4 > text.length()) { + throw new IOException("Bad JSON unicode escape."); + } + try { + sb.append((char)Integer.parseInt(text.substring(index, index + 4), 16)); + } + catch (final NumberFormatException e) { + throw new IOException("Bad JSON unicode escape.", e); + } + index += 4; + } + default -> throw new IOException("Bad JSON escape."); + } + } + throw new IOException("Unterminated JSON string."); + } + + private Object readNumber() throws IOException { + final int start = index; + while (index < text.length() && "-+0123456789.eE".indexOf(text.charAt(index)) >= 0) { + index++; + } + final String number = text.substring(start, index); + if (number.isEmpty()) { + throw new IOException("Expected JSON value."); + } + try { + return number.contains(".") || number.contains("e") || number.contains("E") + ? Double.parseDouble(number) + : Long.parseLong(number); + } + catch (final NumberFormatException e) { + throw new IOException("Bad JSON number.", e); + } + } + + private Object readLiteral(final String literal, final Object value) throws IOException { + if (!text.startsWith(literal, index)) { + throw new IOException("Bad JSON literal."); + } + index += literal.length(); + return value; + } + + private void expect(final char c) throws IOException { + if (index >= text.length() || text.charAt(index) != c) { + throw new IOException("Expected '" + c + "'."); + } + index++; + } + + private boolean peek(final char c) { + return index < text.length() && text.charAt(index) == c; + } + + private void skipWhitespace() { + while (index < text.length() && Character.isWhitespace(text.charAt(index))) { + index++; + } + } + } +} diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java b/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java index 269b5202d07..a29a51bf9c3 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java @@ -8,16 +8,20 @@ import java.awt.event.MouseEvent; import java.util.*; import java.util.Map.Entry; +import java.util.function.Consumer; import javax.swing.JMenu; import javax.swing.JTable; +import javax.swing.SwingUtilities; import forge.itemmanager.filters.*; import forge.itemmanager.views.CommanderBracketView; +import forge.itemmanager.views.ItemView; import forge.localinstance.properties.ForgePreferences; import org.apache.commons.lang3.StringUtils; import forge.Singletons; +import forge.deck.CommanderBracketService; import forge.deck.Deck; import forge.deck.DeckBase; import forge.deck.DeckGroup; @@ -63,6 +67,8 @@ public final class DeckManager extends ItemManager implements IHasGam //private static final FSkin.SkinIcon icoEditOver = FSkin.getIcon(FSkinProp.ICO_EDIT_OVER); private final GameType gameType; + private final Consumer commanderBracketUpdateListener = + update -> SwingUtilities.invokeLater(() -> updateCommanderBracketView(update)); private UiCommand cmdDelete, cmdSelect; /** @@ -77,6 +83,7 @@ public DeckManager(final GameType gt, final CDetailPicture cDetailPicture) { if (gt.getDeckFormat() == DeckFormat.Commander) { this.addView(new CommanderBracketView(this)); + CommanderBracketService.addUpdateListener(commanderBracketUpdateListener); } this.addSelectionListener(e -> { @@ -88,6 +95,31 @@ public DeckManager(final GameType gt, final CDetailPicture cDetailPicture) { this.setItemActivateCommand((UiCommand) () -> editDeck(getSelectedItem())); } + private void updateCommanderBracketView(final CommanderBracketService.BracketUpdate update) { + if (update == null || this.getPool() == null || this.getPool().countAll() == 0) { + return; + } + + if (isCommanderBracketSortActive()) { + this.refresh(); + return; + } + + final ItemView currentView = this.getCurrentView(); + if (currentView instanceof CommanderBracketView) { + currentView.refresh(this.getSelectedItems(), this.getSelectedIndex(), currentView.getScrollValue()); + } + else { + currentView.getComponent().repaint(); + } + } + + private boolean isCommanderBracketSortActive() { + return this.getConfig() != null + && this.getConfig().getCols().containsKey(ColumnDef.DECK_BRACKET) + && this.getConfig().getCols().get(ColumnDef.DECK_BRACKET).getSortPriority() > 0; + } + @Override public GameType getGameType() { return gameType; diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketDeckView.java b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketDeckView.java index 50b34489756..f260c2f955b 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketDeckView.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketDeckView.java @@ -1,6 +1,6 @@ package forge.itemmanager.views; -import forge.deck.CommanderBracketCalculator; +import forge.deck.CommanderBracketService; import forge.deck.Deck; import forge.item.PaperCard; import forge.itemmanager.CardManager; @@ -18,6 +18,11 @@ public CommanderBracketDeckView(final CardManager itemManager0, final ItemManage @Override protected String getText() { - return deck.getName() + "\n\n" + CommanderBracketCalculator.getExplanation(deck); + return deck.getName() + "\n\n" + CommanderBracketService.getExplanation(deck); + } + + @Override + protected boolean isRefreshPending() { + return CommanderBracketService.isPending(deck); } } diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketTextView.java b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketTextView.java index 75ce6d1af11..0c40776ca2e 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketTextView.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketTextView.java @@ -6,17 +6,27 @@ import forge.itemmanager.ItemManagerConfig; import forge.itemmanager.ItemManagerModel; import forge.localinstance.skin.FSkinProp; +import forge.menus.MenuUtil; import forge.toolbox.FLabel; import forge.toolbox.FPanel; import forge.toolbox.FSkin; import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.ScrollPaneConstants; +import javax.swing.Scrollable; import javax.swing.JTextArea; +import javax.swing.Timer; import javax.swing.JViewport; import javax.swing.border.EmptyBorder; import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.Dimension; import java.awt.Font; import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -24,12 +34,17 @@ @SuppressWarnings("serial") abstract class CommanderBracketTextView extends ItemView { - private final FPanel panel = new FPanel(new BorderLayout()); + private static final String ATTRIBUTION_URL = "https://commanderbracket.app/?ref=forge"; + private static final String LINK_COLOR = "#1f66cc"; + private final FPanel panel = new BracketPanel(); private final JTextArea textArea = new JTextArea(); + private final JLabel attributionLabel = new JLabel(); + private final Timer refreshTimer = new Timer(1500, e -> updateText()); private int selectedIndex = -1; CommanderBracketTextView(final ItemManager itemManager0, final ItemManagerModel model0) { super(itemManager0, model0); + this.getScroller().setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); this.panel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE)); this.panel.setBorderToggle(false); this.textArea.setEditable(false); @@ -40,7 +55,23 @@ abstract class CommanderBracketTextView extends ItemVie this.textArea.setForeground(FSkin.getColor(FSkin.Colors.CLR_TEXT).getColor()); this.textArea.setCaretColor(FSkin.getColor(FSkin.Colors.CLR_TEXT).getColor()); this.textArea.setBorder(new EmptyBorder(8, 8, 8, 8)); + this.attributionLabel.setFont(FSkin.getFont(13).getBaseFont()); + this.attributionLabel.setForeground(FSkin.getColor(FSkin.Colors.CLR_TEXT).getColor()); + this.attributionLabel.setText(getAttributionHtml()); + this.attributionLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + this.attributionLabel.setBorder(new EmptyBorder(0, 8, 8, 8)); + this.attributionLabel.setToolTipText("" + localizer.getMessage("lblCommanderBracketExplore") + + "
" + ATTRIBUTION_URL + ""); + this.attributionLabel.setVisible(false); + this.attributionLabel.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(final MouseEvent e) { + MenuUtil.openUrlInBrowser(ATTRIBUTION_URL); + } + }); + this.refreshTimer.setRepeats(true); this.panel.add(textArea, BorderLayout.CENTER); + this.panel.add(attributionLabel, BorderLayout.SOUTH); this.getButton().setBorder(new EmptyBorder(4, 0, 0, 0)); this.getPnlOptions().setVisible(false); } @@ -151,6 +182,7 @@ protected void onScrollSelectionIntoView(final JViewport viewport) { @Override protected void onResize() { + panel.revalidate(); } @Override @@ -162,9 +194,96 @@ protected void onRefresh() { } protected final void updateText() { - textArea.setText(getText()); + final String text = getText(); + final boolean hasAttribution = text.contains(getAttributionLabel()); + textArea.setText(stripAttribution(text)); + attributionLabel.setVisible(hasAttribution); textArea.setCaretPosition(0); + updateRefreshTimer(); } protected abstract String getText(); + + protected boolean isRefreshPending() { + return false; + } + + private final class BracketPanel extends FPanel implements Scrollable { + private BracketPanel() { + super(new BorderLayout()); + } + + @Override + public Dimension getPreferredSize() { + final Dimension preferredSize = super.getPreferredSize(); + final int viewportWidth = getScroller().getViewport().getWidth(); + if (viewportWidth > 0) { + preferredSize.width = viewportWidth; + } + return preferredSize; + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(final Rectangle visibleRect, final int orientation, final int direction) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(final Rectangle visibleRect, final int orientation, final int direction) { + return Math.max(16, visibleRect.height - 16); + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + final int viewportHeight = getScroller().getViewport().getHeight(); + return viewportHeight > 0 && getPreferredSize().height < viewportHeight; + } + } + + private void updateRefreshTimer() { + if (isRefreshPending()) { + if (!refreshTimer.isRunning()) { + refreshTimer.start(); + } + } + else if (refreshTimer.isRunning()) { + refreshTimer.stop(); + } + } + + private String getAttributionLabel() { + return localizer.getMessage("lblCommanderBracketAttribution"); + } + + private String getAttributionHtml() { + return "" + localizer.getMessage("lblCommanderBracketPoweredBy") + + " " + + localizer.getMessage("lblCommanderBracketSiteName") + + ""; + } + + private String stripAttribution(final String text) { + final StringBuilder result = new StringBuilder(); + final String[] lines = text.split("\\R", -1); + for (final String line : lines) { + if (getAttributionLabel().equals(line.trim())) { + continue; + } + if (result.length() > 0) { + result.append('\n'); + } + result.append(line); + } + return result.toString(); + } } diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketView.java b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketView.java index 05d377b28ba..f2360782827 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketView.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/views/CommanderBracketView.java @@ -1,12 +1,16 @@ package forge.itemmanager.views; -import forge.deck.CommanderBracketCalculator; +import forge.deck.CommanderBracketService; +import forge.deck.Deck; import forge.deck.DeckProxy; import forge.itemmanager.DeckManager; import forge.itemmanager.ItemManagerModel; @SuppressWarnings("serial") public final class CommanderBracketView extends CommanderBracketTextView { + private DeckProxy materializedProxy; + private Deck materializedDeck; + public CommanderBracketView(final DeckManager itemManager0) { super(itemManager0, getModel(itemManager0)); } @@ -19,8 +23,23 @@ private static ItemManagerModel getModel(final DeckManager itemManage protected String getText() { final DeckProxy deck = getSelectedItem(); if (deck == null) { + materializedProxy = null; + materializedDeck = null; return localizer.getMessage("lblCommanderBracketSelectDeck"); } - return deck.getName() + "\n\n" + CommanderBracketCalculator.getExplanation(deck.getDeck()); + return deck.getName() + "\n\n" + CommanderBracketService.getExplanation(getMaterializedDeck(deck), deck); + } + + @Override + protected boolean isRefreshPending() { + return CommanderBracketService.isPending(materializedDeck); + } + + private Deck getMaterializedDeck(final DeckProxy deck) { + if (deck != materializedProxy) { + materializedProxy = deck; + materializedDeck = deck.getDeck(); + } + return materializedDeck; } } 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 9915861a8fa..daa538563b1 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 @@ -163,6 +163,7 @@ public void initialize() { lstControls.add(Pair.of(view.getCbSmartCardArtSelectionOpt(), FPref.UI_SMART_CARD_ART)); lstControls.add(Pair.of(view.getCbShowDraftRanking(), FPref.UI_OVERLAY_DRAFT_RANKING)); lstControls.add(Pair.of(view.getCbAiPicker(), FPref.UI_ENABLE_AI_PICKER)); + lstControls.add(Pair.of(view.getCbUseCommanderBracketApi(), FPref.UI_USE_COMMANDER_BRACKET_API)); for(final Pair kv : lstControls) { 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 c0141ae4788..f090db55f34 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 @@ -124,6 +124,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbEnableNonLegalCards = new OptionsCheckBox(localizer.getMessage("lblEnableNonLegalCards")); private final JCheckBox cbAllowCustomCardsDeckConformance = new OptionsCheckBox(localizer.getMessage("lblAllowCustomCardsInDecks")); private final JCheckBox cbAiPicker = new OptionsCheckBox(localizer.getMessage("lblAiPickerSettings")); + private final JCheckBox cbUseCommanderBracketApi = new OptionsCheckBox(localizer.getMessage("cbUseCommanderBracketApi")); private final JCheckBox cbCardArtCoreExpansionsOnlyOpt = new OptionsCheckBox(localizer.getMessage("lblPrefArtExpansionOnly")); private final JCheckBox cbSmartCardArtSelectionOpt = new OptionsCheckBox(localizer.getMessage("lblSmartCardArtOpt")); private final JCheckBox cbShowDraftRanking = new OptionsCheckBox(localizer.getMessage("lblShowDraftRankingOverlay")); @@ -383,6 +384,9 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbAiPicker, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlAiPickerSettings")), descriptionConstraints); + pnlPrefs.add(cbUseCommanderBracketApi, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlUseCommanderBracketApi")), descriptionConstraints); + // Graphic Options pnlPrefs.add(new SectionLabel(localizer.getMessage("GraphicOptions")), sectionConstraints + ", gaptop 2%"); @@ -1084,6 +1088,10 @@ public final JCheckBox getCbAiPicker() { return cbAiPicker; } + public final JCheckBox getCbUseCommanderBracketApi() { + return cbUseCommanderBracketApi; + } + public final FComboBoxPanel getCbpGraveyardOrdering() { return cbpGraveyardOrdering; } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java index 01c6b3524f1..3a1adc940ed 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java @@ -27,7 +27,7 @@ import javax.swing.border.Border; import javax.swing.border.LineBorder; -import forge.deck.CommanderBracketCalculator; +import forge.deck.CommanderBracketService; import forge.deck.Deck; import forge.game.GameType; import forge.game.card.CounterEnumType; @@ -460,7 +460,7 @@ private String getCommanderBracketTooltipLine() { } commanderBracketTooltipLine = Localizer.getInstance().getMessage("lblBracket") - + ": " + CommanderBracketCalculator.getBracket(deck); + + ": " + CommanderBracketService.getBestAvailableBracket(deck); return commanderBracketTooltipLine; } diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index b1dbc53865d..e44fb6c926b 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -217,6 +217,8 @@ nlWorkshopSyntax=Aktiviert den Syntaxcheck für Kartenskripte im Workshop. Hinwe nlGameLogEntryType=Steuert den Umfang der Daten in der Protokolldatei. Sortiert vom geringsten zum größten Umfang. nlCloseAction=Steuert was passiert, wenn X oben rechts gedrückt wird. nlLoadCardsLazily=Wenn aktiviert, lädt Forge Kartenskripte erst wenn sie benötigt werden, nicht bei Programmstart. Warnung: Experimentell!!! +cbUseCommanderBracketApi=CommanderBracket.app für Gruppenberechnungen verwenden +nlUseCommanderBracketApi=Wenn aktiviert, kann Forge CommanderBracket.app kontaktieren, um detailliertere Commander-Gruppenschätzungen zu berechnen. Gespeicherte Schätzungen bleiben sichtbar, wenn diese Option deaktiviert ist. nlLoadArchivedFormats=Wenn aktiviert, lädt Forge auch archivierte Spielformate. Verlängert den Programmstart. (Erfordert Neustart) GraphicOptions=Grafik Optionen nlDefaultFontSize=Die Standardschriftgröße. Alle Schriftelemente werden relative zu dieser angepaßt. (Erfordert Neustart) @@ -3570,6 +3572,22 @@ ttCommanderBracket=Vorgeschlagene Mindest-Einstufungsgruppe für Commander lblBracketView=Gruppenansicht lblCommanderBracketMinimum=Commander-Mindestgruppe: {0} lblCommanderBracketSelectDeck=Wählen Sie ein Commander-Deck aus, um die Gruppenerklärung anzuzeigen. +lblCommanderBracketAttribution=Unterstützt von Commander Bracket +lblCommanderBracketPoweredBy=Unterstützt von +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Commander Bracket öffnen +lblCommanderBracketEnableApi=Aktivieren Sie CommanderBracket.app unter Einstellungen -> Erweiterte Optionen, um Gruppendetails zu berechnen oder zu aktualisieren. +lblCommanderBracketRefreshingDetails=CommanderBracket.app-Details werden aktualisiert... +lblCommanderBracketAnalysisQueued=CommanderBracket.app-Analyse in der Warteschlange. Forge zeigt die lokale Mindestgruppe an, während auf den öffentlichen Analyzer gewartet wird. +lblCommanderBracketAppEstimate=CommanderBracket.app-Schätzung: {0} +lblCommanderBracketDescriptionLabel=Beschreibung +lblCommanderBracketReasonLabel=Begründung +lblCommanderBracketNarrativeLabel=Erläuterung +lblCommanderBracketEstimatedWinTurnLabel=Geschätzter Siegzug +lblCommanderBracketConfidenceLabel=Konfidenz +lblCommanderBracketConfidenceReasonLabel=Konfidenzbegründung +lblCommanderBracketCardsFound=Gefundene Karten: {0}/{1} +lblCommanderBracketSignals=Signale: Game Changers {0}, schnelles Mana {1}, Tutoren {2}, Kombos {3} lblCommanderBracketGameChangers=Game Changers lblCommanderBracketMassLandDenial=Massenlandverweigerung lblCommanderBracketExtraTurns=Extrazüge diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 67867663e76..adb8ad9b6c3 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -234,6 +234,8 @@ nlWorkshopSyntax=Enables syntax checking of card scripts in the Workshop. Note: nlGameLogEntryType=Changes how much information is displayed in the game log. Low shows turns and damage, Medium adds spells and combat, High shows everything. Custom lets you pick individual categories. nlCloseAction=Changes what happens when clicking the X button in the upper right. nlLoadCardsLazily=If turned on, Forge will load card scripts as they''re needed instead of at start up. (DISABLED: PROBLEMATIC) +cbUseCommanderBracketApi=Use CommanderBracket.app for bracket calculations +nlUseCommanderBracketApi=If turned on, Forge may contact CommanderBracket.app to calculate more detailed Commander bracket estimates. Saved estimates remain visible when this is off. nlLoadArchivedFormats=If turned on, Forge will load all archived format definitions, this may take slightly longer to load at startup. (REQUIRES RESTART) GraphicOptions=Graphic Options nlCardArtFormat=The format of card art images. (Full: image of entire card. Crop: only the art part) @@ -3580,6 +3582,22 @@ ttCommanderBracket=Suggested minimum Commander bracket lblBracketView=Bracket View lblCommanderBracketMinimum=Minimum Commander Bracket: {0} lblCommanderBracketSelectDeck=Select a Commander deck to see its bracket explanation. +lblCommanderBracketAttribution=Powered by Commander Bracket +lblCommanderBracketPoweredBy=Powered by +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Explore Commander Bracket +lblCommanderBracketEnableApi=Turn on CommanderBracket.app in Preferences -> Advanced Settings to calculate or refresh bracket details. +lblCommanderBracketRefreshingDetails=Refreshing CommanderBracket.app details... +lblCommanderBracketAnalysisQueued=CommanderBracket.app analysis queued. Showing Forge''s local minimum bracket while waiting for the public analyzer. +lblCommanderBracketAppEstimate=CommanderBracket.app estimate: {0} +lblCommanderBracketDescriptionLabel=Description +lblCommanderBracketReasonLabel=Reason +lblCommanderBracketNarrativeLabel=Narrative +lblCommanderBracketEstimatedWinTurnLabel=Estimated win turn +lblCommanderBracketConfidenceLabel=Confidence +lblCommanderBracketConfidenceReasonLabel=Confidence reason +lblCommanderBracketCardsFound=Cards found: {0}/{1} +lblCommanderBracketSignals=Signals: game changers {0}, fast mana {1}, tutors {2}, combos {3} lblCommanderBracketGameChangers=Game Changers lblCommanderBracketMassLandDenial=Mass Land Denial lblCommanderBracketExtraTurns=Extra Turns @@ -3597,4 +3615,4 @@ lblCommanderBracketReasonExtraTurnsTwo=2 Extra Turn cards make the deck at least lblCommanderBracketReasonExtraTurnsFew=Fewer than 2 Extra Turn cards do not raise the bracket. lblCommanderBracketReasonLateGameCombo=Late Game 2-card combos make the deck at least Bracket 3. lblCommanderBracketReasonEarlyGameCombo=Early Game 2-card combos make the deck at least Bracket 4. -lblAutoSellVariantsCommander=Auto-sell variants of owned cards in Commander \ No newline at end of file +lblAutoSellVariantsCommander=Auto-sell variants of owned cards in Commander diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index bf7aaf3325e..2bbff8caacf 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -193,6 +193,8 @@ nlWorkshopSyntax=Habilita la comprobación de sintaxis de los guiones de cartas nlGameLogEntryType=Cambia la cantidad de información que se muestra en el registro del juego. Ordenado de menos a más detallado. nlCloseAction=Cambia lo que sucede al hacer clic en el botón X en la parte superior derecha. nlLoadCardsLazily=Si está activado, Forge cargará los scripts de las cartas según sea necesario en lugar de al inicio. (Advertencia: Experimental) +cbUseCommanderBracketApi=Usar CommanderBracket.app para calcular grupos +nlUseCommanderBracketApi=Si está activado, Forge puede contactar con CommanderBracket.app para calcular estimaciones más detalladas del grupo de Commander. Las estimaciones guardadas siguen siendo visibles cuando está desactivado. nlLoadArchivedFormats=Si se enciende, Forge cargará todas las definiciones de formato archivado, esto puede tardar un poco más en cargarse al inicio. (Requiere reiniciar) GraphicOptions=Opciones gráficas nlDefaultFontSize=El tamaño de fuente predeterminado dentro de la interfaz de usuario. Todos los elementos de fuente se escalan en relación a esto. (Necesita reinicio) @@ -3551,6 +3553,22 @@ ttCommanderBracket=Grupo mínimo sugerido para Commander lblBracketView=Vista de grupo lblCommanderBracketMinimum=Grupo mínimo de Commander: {0} lblCommanderBracketSelectDeck=Selecciona un mazo de Commander para ver la explicación del grupo. +lblCommanderBracketAttribution=Con tecnología de Commander Bracket +lblCommanderBracketPoweredBy=Con tecnología de +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Explorar Commander Bracket +lblCommanderBracketEnableApi=Activa CommanderBracket.app en Preferencias -> Ajustes avanzados para calcular o actualizar los detalles del grupo. +lblCommanderBracketRefreshingDetails=Actualizando detalles de CommanderBracket.app... +lblCommanderBracketAnalysisQueued=Análisis de CommanderBracket.app en cola. Mostrando el grupo mínimo local de Forge mientras se espera al analizador público. +lblCommanderBracketAppEstimate=Estimación de CommanderBracket.app: {0} +lblCommanderBracketDescriptionLabel=Descripción +lblCommanderBracketReasonLabel=Motivo +lblCommanderBracketNarrativeLabel=Explicación +lblCommanderBracketEstimatedWinTurnLabel=Turno de victoria estimado +lblCommanderBracketConfidenceLabel=Confianza +lblCommanderBracketConfidenceReasonLabel=Motivo de confianza +lblCommanderBracketCardsFound=Cartas encontradas: {0}/{1} +lblCommanderBracketSignals=Señales: cartas decisivas {0}, maná rápido {1}, tutores {2}, combos {3} lblCommanderBracketGameChangers=Cartas decisivas lblCommanderBracketMassLandDenial=Neutralización masiva de tierras lblCommanderBracketExtraTurns=Turnos adicionales diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index 1f0effb1c69..9862b7336d4 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -191,6 +191,8 @@ nlWorkshopSyntax=Active la vérification de la syntaxe des scripts de carte dans nlGameLogEntryType=Modifie la quantité d'informations affichées dans le journal de jeu. Trié du moins au plus verbeux. nlCloseAction=Modifie ce qui se passe lorsque vous cliquez sur le bouton X en haut à droite. nlLoadCardsLazily=Si activé, Forge chargera les scripts de carte au fur et à mesure qu'ils seront nécessaires plutôt qu'au démarrage. (Attention : Expérimental) +cbUseCommanderBracketApi=Utiliser CommanderBracket.app pour calculer les catégories +nlUseCommanderBracketApi=Si activé, Forge peut contacter CommanderBracket.app pour calculer des estimations plus détaillées de catégorie Commander. Les estimations enregistrées restent visibles lorsque cette option est désactivée. nlLoadArchivedFormats=Si activé, Forge chargera toutes les définitions de format archivées, cela peut prendre un peu plus de temps à charger au démarrage. (Nécessite un redémarrage) GraphicOptions=Options graphiques nlCardArtFormat=Le format des images d'art de la carte. (Plein : image de la carte entière. Recadrage : uniquement la partie graphique) @@ -3552,6 +3554,22 @@ ttCommanderBracket=Catégorie minimum suggérée pour Commander lblBracketView=Vue des catégories lblCommanderBracketMinimum=Catégorie Commander minimum : {0} lblCommanderBracketSelectDeck=Sélectionnez un deck Commander pour voir l’explication de la catégorie. +lblCommanderBracketAttribution=Propulsé par Commander Bracket +lblCommanderBracketPoweredBy=Propulsé par +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Explorer Commander Bracket +lblCommanderBracketEnableApi=Activez CommanderBracket.app dans Préférences -> Paramètres avancés pour calculer ou actualiser les détails de catégorie. +lblCommanderBracketRefreshingDetails=Actualisation des détails de CommanderBracket.app... +lblCommanderBracketAnalysisQueued=Analyse CommanderBracket.app en file d’attente. Affichage de la catégorie minimum locale de Forge en attendant l’analyseur public. +lblCommanderBracketAppEstimate=Estimation CommanderBracket.app : {0} +lblCommanderBracketDescriptionLabel=Description +lblCommanderBracketReasonLabel=Raison +lblCommanderBracketNarrativeLabel=Explication +lblCommanderBracketEstimatedWinTurnLabel=Tour de victoire estimé +lblCommanderBracketConfidenceLabel=Confiance +lblCommanderBracketConfidenceReasonLabel=Raison de la confiance +lblCommanderBracketCardsFound=Cartes trouvées : {0}/{1} +lblCommanderBracketSignals=Signaux : cartes à impact {0}, mana rapide {1}, tuteurs {2}, combos {3} lblCommanderBracketGameChangers=Cartes à impact lblCommanderBracketMassLandDenial=Refus de terrains massif lblCommanderBracketExtraTurns=Tours supplémentaires diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index 561f81bb86e..9725852e090 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -190,6 +190,8 @@ nlWorkshopSyntax=Abilita il controllo della sintassi degli script delle carte ne nlGameLogEntryType=Modifica la quantità di informazioni visualizzate nel registro di gioco. Ordinati dal meno al più dettagliato. nlCloseAction=Cambia ciò che accade quando si fa clic sul pulsante X in alto a destra. nlLoadCardsLazily=Se attivato, Forge caricherà gli script delle carte quando sono necessari anziché all'avvio. (Avvertenza: sperimentale) +cbUseCommanderBracketApi=Usa CommanderBracket.app per calcolare le fasce +nlUseCommanderBracketApi=Se attivato, Forge può contattare CommanderBracket.app per calcolare stime più dettagliate della fascia Commander. Le stime salvate restano visibili quando questa opzione è disattivata. nlLoadArchivedFormats=Se attivo, Forge caricherà tutti i formati di Magic archiviati. Questo potrà comportare dei tempi di avvio leggermente più lunghi. (RIAVVIO NECESSARIO) GraphicOptions=Opzioni grafiche nlDefaultFontSize=La dimensione predefinita dei caratteri nell'interfaccia utente. Tutti i caratteri sono ridimensionati rispetto a questo valore. (RIAVVIO NECESSARIO) @@ -3550,6 +3552,22 @@ ttCommanderBracket=Fascia minima suggerita per Commander lblBracketView=Vista fasce lblCommanderBracketMinimum=Fascia Commander minima: {0} lblCommanderBracketSelectDeck=Seleziona un mazzo Commander per vedere la spiegazione della fascia. +lblCommanderBracketAttribution=Basato su Commander Bracket +lblCommanderBracketPoweredBy=Basato su +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Esplora Commander Bracket +lblCommanderBracketEnableApi=Attiva CommanderBracket.app in Preferenze -> Impostazioni avanzate per calcolare o aggiornare i dettagli della fascia. +lblCommanderBracketRefreshingDetails=Aggiornamento dei dettagli di CommanderBracket.app... +lblCommanderBracketAnalysisQueued=Analisi di CommanderBracket.app in coda. Viene mostrata la fascia minima locale di Forge in attesa dell''analizzatore pubblico. +lblCommanderBracketAppEstimate=Stima di CommanderBracket.app: {0} +lblCommanderBracketDescriptionLabel=Descrizione +lblCommanderBracketReasonLabel=Motivo +lblCommanderBracketNarrativeLabel=Spiegazione +lblCommanderBracketEstimatedWinTurnLabel=Turno di vittoria stimato +lblCommanderBracketConfidenceLabel=Affidabilità +lblCommanderBracketConfidenceReasonLabel=Motivo dell''affidabilità +lblCommanderBracketCardsFound=Carte trovate: {0}/{1} +lblCommanderBracketSignals=Segnali: Game Changer {0}, mana veloce {1}, tutori {2}, combo {3} lblCommanderBracketGameChangers=Game Changer lblCommanderBracketMassLandDenial=Negazione di massa delle terre lblCommanderBracketExtraTurns=Turni extra diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index 0413259cbe2..16fd6257fb2 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -191,6 +191,8 @@ nlWorkshopSyntax=ワークショップでカードスクリプトの構文チェ nlGameLogEntryType=ゲームログに表示される情報の量を変更します。 最小から最大の詳細度でソートされています nlCloseAction=右上の[X]ボタンをクリックしたときの動作を変更します。 nlLoadCardsLazily=オンにすると、Forge は起動時にではなく、必要に応じてカードスクリプトをロードします。 (警告:実験的) +cbUseCommanderBracketApi=ブラケット計算に CommanderBracket.app を使用する +nlUseCommanderBracketApi=有効にすると、Forge は CommanderBracket.app に接続して統率者戦ブラケットのより詳細な推定を計算できます。無効の場合でも、保存済みの推定は表示されます。 nlLoadArchivedFormats=オンになった場合、Forgeはすべてのアーカイブ形式の定義をロードします。これは、起動時のロードに少し時間がかかる場合があります。 (再起動が必要) GraphicOptions=グラフィックオプション nlDefaultFontSize=UI 内のデフォルトのフォントサイズ。 すべてのフォント要素はこれに比例して拡大縮小されます。 (再起動が必要) @@ -3546,6 +3548,22 @@ ttCommanderBracket=統率者の推奨最低ブラケット lblBracketView=ブラケット表示 lblCommanderBracketMinimum=統率者戦の最低ブラケット: {0} lblCommanderBracketSelectDeck=統率者デッキを選択すると、ブラケットの説明が表示されます。 +lblCommanderBracketAttribution=Powered by Commander Bracket +lblCommanderBracketPoweredBy=Powered by +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Commander Bracket を開く +lblCommanderBracketEnableApi=ブラケットの詳細を計算または更新するには、設定 -> 詳細設定で CommanderBracket.app を有効にしてください。 +lblCommanderBracketRefreshingDetails=CommanderBracket.app の詳細を更新中... +lblCommanderBracketAnalysisQueued=CommanderBracket.app の分析がキューに入りました。公開アナライザーの応答を待つ間、Forge のローカル最低ブラケットを表示しています。 +lblCommanderBracketAppEstimate=CommanderBracket.app の推定: {0} +lblCommanderBracketDescriptionLabel=説明 +lblCommanderBracketReasonLabel=理由 +lblCommanderBracketNarrativeLabel=解説 +lblCommanderBracketEstimatedWinTurnLabel=推定勝利ターン +lblCommanderBracketConfidenceLabel=信頼度 +lblCommanderBracketConfidenceReasonLabel=信頼度の理由 +lblCommanderBracketCardsFound=検出カード: {0}/{1} +lblCommanderBracketSignals=シグナル: ゲームチェンジャー {0}、高速マナ {1}、サーチ {2}、コンボ {3} lblCommanderBracketGameChangers=ゲームチェンジャー lblCommanderBracketMassLandDenial=大量土地妨害 lblCommanderBracketExtraTurns=追加ターン diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 290195bb399..2cbccee8a7d 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -222,6 +222,8 @@ nlWorkshopSyntax=워크숍에서 카드 스크립트 구문 검사를 활성화 nlGameLogEntryType=게임 로그에 표시되는 정보량을 변경합니다. 최소에서 최대 상세도까지 정렬됩니다. nlCloseAction=오른쪽 상단의 [X] 버튼 클릭 시 동작을 변경합니다. nlLoadCardsLazily=켜면, Forge는 시작 시가 아니라 필요에 따라 카드 스크립트를 로드합니다(경고: 실험적). +cbUseCommanderBracketApi=브래킷 계산에 CommanderBracket.app 사용 +nlUseCommanderBracketApi=켜면 Forge가 CommanderBracket.app에 접속해 더 자세한 커맨더 브래킷 추정치를 계산할 수 있습니다. 이 옵션이 꺼져 있어도 저장된 추정치는 계속 표시됩니다. nlLoadArchivedFormats=켜면, Forge는 모든 아카이브 형식 정의를 로드합니다. 이는 시작 시 로드에 약간 시간이 걸릴 수 있습니다(재시작 필요). GraphicOptions=그래픽 옵션 nlCardArtFormat=카드 그릴 때 사용할 아트 포맷 (Full: 카드 전체 이미지 사용, Crop: 카드 아트 부분만 사용). @@ -3332,6 +3334,22 @@ ttCommanderBracket=추천 최소 커맨더 브래킷 lblBracketView=브래킷 보기 lblCommanderBracketMinimum=커맨더 최소 브래킷: {0} lblCommanderBracketSelectDeck=브래킷 설명을 보려면 커맨더 덱을 선택하세요. +lblCommanderBracketAttribution=Powered by Commander Bracket +lblCommanderBracketPoweredBy=Powered by +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Commander Bracket 열기 +lblCommanderBracketEnableApi=브래킷 세부 정보를 계산하거나 새로 고치려면 환경설정 -> 상세 설정에서 CommanderBracket.app을 켜세요. +lblCommanderBracketRefreshingDetails=CommanderBracket.app 세부 정보 새로 고치는 중... +lblCommanderBracketAnalysisQueued=CommanderBracket.app 분석이 대기열에 추가되었습니다. 공개 분석기를 기다리는 동안 Forge의 로컬 최소 브래킷을 표시합니다. +lblCommanderBracketAppEstimate=CommanderBracket.app 추정치: {0} +lblCommanderBracketDescriptionLabel=설명 +lblCommanderBracketReasonLabel=이유 +lblCommanderBracketNarrativeLabel=해설 +lblCommanderBracketEstimatedWinTurnLabel=예상 승리 턴 +lblCommanderBracketConfidenceLabel=신뢰도 +lblCommanderBracketConfidenceReasonLabel=신뢰도 이유 +lblCommanderBracketCardsFound=찾은 카드: {0}/{1} +lblCommanderBracketSignals=신호: 게임 체인저 {0}, 빠른 마나 {1}, 튜터 {2}, 콤보 {3} lblCommanderBracketGameChangers=게임 체인저 lblCommanderBracketMassLandDenial=대량 대지 방해 lblCommanderBracketExtraTurns=추가 턴 diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index de075d3b171..a9016b63a4a 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -192,6 +192,8 @@ nlWorkshopSyntax=Habilita a verificação de sintaxe dos scripts da carta na Ofi nlGameLogEntryType=Altera a quantidade de informações exibidas no log do jogo. Ordenado do menor para o maior nível de detalhe. nlCloseAction=Altera o que acontece ao clicar no botão X no canto superior direito. nlLoadCardsLazily=Se ativado, o Forge carregará os scripts da carta conforme necessário em vez de ao iniciar o jogo. (Aviso\: Experimental) +cbUseCommanderBracketApi=Usar CommanderBracket.app para calcular grupos +nlUseCommanderBracketApi=Se ativado, o Forge poderá contatar CommanderBracket.app para calcular estimativas mais detalhadas do grupo de Commander. Estimativas salvas continuam visíveis quando esta opção está desativada. nlLoadArchivedFormats=Se ativado, a Forge carregará todas as definições de formato arquivado, isso pode levar um pouco mais para carregar na inicialização. (REQUER REINÍCIO) GraphicOptions=Opções Gráficas nlCardArtFormat=O formato das imagens de arte da carta. (Total\: imagem inteira da carta. Recorte\: apenas a parte da arte) @@ -3635,6 +3637,22 @@ ttCommanderBracket=Grupo mínimo sugerido para Commander lblBracketView=Visão de grupo lblCommanderBracketMinimum=Grupo mínimo de Commander: {0} lblCommanderBracketSelectDeck=Selecione um deck de Commander para ver a explicação do grupo. +lblCommanderBracketAttribution=Desenvolvido com Commander Bracket +lblCommanderBracketPoweredBy=Desenvolvido com +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=Explorar Commander Bracket +lblCommanderBracketEnableApi=Ative CommanderBracket.app em Preferências -> Configurações Avançadas para calcular ou atualizar detalhes do grupo. +lblCommanderBracketRefreshingDetails=Atualizando detalhes de CommanderBracket.app... +lblCommanderBracketAnalysisQueued=Análise de CommanderBracket.app na fila. Mostrando o grupo mínimo local do Forge enquanto aguarda o analisador público. +lblCommanderBracketAppEstimate=Estimativa de CommanderBracket.app: {0} +lblCommanderBracketDescriptionLabel=Descrição +lblCommanderBracketReasonLabel=Motivo +lblCommanderBracketNarrativeLabel=Explicação +lblCommanderBracketEstimatedWinTurnLabel=Turno de vitória estimado +lblCommanderBracketConfidenceLabel=Confiança +lblCommanderBracketConfidenceReasonLabel=Motivo da confiança +lblCommanderBracketCardsFound=Cards encontrados: {0}/{1} +lblCommanderBracketSignals=Sinais: cards decisivos {0}, mana rápida {1}, tutores {2}, combos {3} lblCommanderBracketGameChangers=Cards decisivos lblCommanderBracketMassLandDenial=Negação massiva de terrenos lblCommanderBracketExtraTurns=Turnos extras diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 75a693f403f..4a9a9998c13 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -193,6 +193,8 @@ nlWorkshopSyntax=在作坊中启用卡牌脚本检查。注意:该功能任在 nlGameLogEntryType=更改游戏中日志显示的信息量。排序为最少到最详细。 nlCloseAction=更改单击右上角X按钮时的动作 nlLoadCardsLazily=如果打开该选项Forge将在使用到卡牌脚本时才加载(警告:实验状态)。 +cbUseCommanderBracketApi=使用 CommanderBracket.app 进行分级计算 +nlUseCommanderBracketApi=启用后,Forge 可以联系 CommanderBracket.app 来计算更详细的指挥官分级估计。关闭此选项时,已保存的估计仍会显示。 nlLoadArchivedFormats=如果启用该选项,Forge将会在启动时加载所有历史赛制,这可能会导致Forge的启动时间明显变。(需要重启) GraphicOptions=图形选项 nlDefaultFontSize=UI中字体的默认大小。所有字体元素都相对于此缩放。(需要重启) @@ -3537,6 +3539,22 @@ ttCommanderBracket=建议的指挥官最低分级 lblBracketView=分级视图 lblCommanderBracketMinimum=指挥官最低分级:{0} lblCommanderBracketSelectDeck=选择一个指挥官套牌以查看分级说明。 +lblCommanderBracketAttribution=由 Commander Bracket 提供支持 +lblCommanderBracketPoweredBy=由以下提供支持: +lblCommanderBracketSiteName=Commander Bracket +lblCommanderBracketExplore=打开 Commander Bracket +lblCommanderBracketEnableApi=在 首选项 -> 高级设置 中启用 CommanderBracket.app,以计算或刷新分级详情。 +lblCommanderBracketRefreshingDetails=正在刷新 CommanderBracket.app 详情... +lblCommanderBracketAnalysisQueued=CommanderBracket.app 分析已排队。在等待公共分析器时,显示 Forge 的本地最低分级。 +lblCommanderBracketAppEstimate=CommanderBracket.app 估计:{0} +lblCommanderBracketDescriptionLabel=描述 +lblCommanderBracketReasonLabel=原因 +lblCommanderBracketNarrativeLabel=说明 +lblCommanderBracketEstimatedWinTurnLabel=预计获胜回合 +lblCommanderBracketConfidenceLabel=置信度 +lblCommanderBracketConfidenceReasonLabel=置信度原因 +lblCommanderBracketCardsFound=找到的牌张:{0}/{1} +lblCommanderBracketSignals=信号:游戏改变者 {0}、快速法术力 {1}、导师 {2}、组合技 {3} lblCommanderBracketGameChangers=游戏改变者 lblCommanderBracketMassLandDenial=大规模地阻 lblCommanderBracketExtraTurns=额外回合 diff --git a/forge-gui/src/main/java/forge/deck/CommanderBracketApiClient.java b/forge-gui/src/main/java/forge/deck/CommanderBracketApiClient.java new file mode 100644 index 00000000000..9df1842e7ac --- /dev/null +++ b/forge-gui/src/main/java/forge/deck/CommanderBracketApiClient.java @@ -0,0 +1,289 @@ +package forge.deck; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; + +import forge.util.BuildInfo; +import forge.util.JsonUtil; + +final class CommanderBracketApiClient { + private static final String API_URL = "https://mtg-assistant.up.railway.app/decks/analyze-complete"; + private static final int CONNECT_TIMEOUT_MILLIS = 10000; + private static final int READ_TIMEOUT_MILLIS = 45000; + private static final long MIN_REQUEST_INTERVAL_MILLIS = 1500L; + + private final ResultHandler resultHandler; + private final Consumer updateListener; + private final Map memoryCache = new ConcurrentHashMap<>(); + private final Map failureCooldowns = new ConcurrentHashMap<>(); + private final Set inFlight = ConcurrentHashMap.newKeySet(); + private final Set active = ConcurrentHashMap.newKeySet(); + private final PriorityQueue queue = new PriorityQueue<>(); + private final AtomicLong sequence = new AtomicLong(); + private final Thread worker; + private long nextRequestTimeMillis = 0L; + + CommanderBracketApiClient(final ResultHandler resultHandler, + final Consumer updateListener) { + this.resultHandler = resultHandler; + this.updateListener = updateListener; + worker = new Thread(this::processQueue, "CommanderBracket API"); + worker.setDaemon(true); + worker.start(); + } + + CommanderBracketService.RemoteResult getCachedResult(final String deckHash) { + return memoryCache.get(deckHash); + } + + boolean enqueue(final Deck deck, final DeckProxy deckProxy, final String decklist, final String deckHash, + final Priority priority) { + if (memoryCache.containsKey(deckHash) || isCoolingDown(deckHash)) { + return false; + } + if (!inFlight.add(deckHash)) { + promoteQueuedRequest(deck, deckProxy, decklist, deckHash, priority); + return true; + } + + synchronized (queue) { + queue.add(new ApiRequest(deck, deckProxy, decklist, deckHash, priority, sequence.incrementAndGet())); + queue.notifyAll(); + } + return true; + } + + boolean isActive(final String deckHash) { + return active.contains(deckHash); + } + + boolean isPending(final String deckHash) { + return inFlight.contains(deckHash); + } + + private void processQueue() { + while (true) { + final ApiRequest request; + synchronized (queue) { + while (queue.isEmpty()) { + try { + queue.wait(); + } + catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + request = queue.poll(); + } + + boolean requeued = false; + try { + active.add(request.deckHash); + updateListener.accept(new CommanderBracketService.BracketUpdate(request.deckHash)); + throttle(); + final CommanderBracketService.RemoteResult result = postDeck(request.decklist, request.deckHash); + failureCooldowns.remove(request.deckHash); + memoryCache.put(request.deckHash, result); + resultHandler.accept(request.deck, request.deckProxy, result); + } + catch (final RetryAfterException e) { + nextRequestTimeMillis = Math.max(nextRequestTimeMillis, System.currentTimeMillis() + e.retryAfterMillis); + requeue(request); + requeued = true; + } + catch (final Exception ignored) { + failureCooldowns.put(request.deckHash, System.currentTimeMillis() + 15L * 60L * 1000L); + } + finally { + active.remove(request.deckHash); + updateListener.accept(new CommanderBracketService.BracketUpdate(request.deckHash)); + if (!requeued) { + inFlight.remove(request.deckHash); + } + } + } + } + + private void promoteQueuedRequest(final Deck deck, final DeckProxy deckProxy, final String decklist, + final String deckHash, final Priority priority) { + if (priority != Priority.HIGH) { + return; + } + synchronized (queue) { + final ApiRequest queued = queue.stream() + .filter(request -> request.deckHash.equals(deckHash) && request.priority == Priority.LOW) + .findFirst() + .orElse(null); + if (queued != null) { + queue.remove(queued); + queue.add(new ApiRequest(deck, deckProxy, decklist, deckHash, Priority.HIGH, sequence.incrementAndGet())); + queue.notifyAll(); + } + } + } + + private boolean isCoolingDown(final String deckHash) { + final Long retryAfter = failureCooldowns.get(deckHash); + if (retryAfter == null) { + return false; + } + if (retryAfter > System.currentTimeMillis()) { + return true; + } + failureCooldowns.remove(deckHash); + return false; + } + + private void requeue(final ApiRequest request) { + synchronized (queue) { + queue.add(request); + queue.notifyAll(); + } + } + + private void throttle() throws InterruptedException { + final long now = System.currentTimeMillis(); + final long waitMillis = Math.max(0L, nextRequestTimeMillis - now); + if (waitMillis > 0L) { + Thread.sleep(waitMillis); + } + nextRequestTimeMillis = System.currentTimeMillis() + MIN_REQUEST_INTERVAL_MILLIS; + } + + private CommanderBracketService.RemoteResult postDeck(final String decklist, final String deckHash) throws IOException { + final HttpURLConnection connection = (HttpURLConnection)new URL(API_URL).openConnection(); + try { + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + + final String commander = firstCommanderName(decklist); + final String escapedDecklist = JsonUtil.escape(decklist); + final String payload = commander == null + ? "{\"decklist\":\"" + escapedDecklist + "\"}" + : "{\"decklist\":\"" + escapedDecklist + "\",\"commander\":\"" + + JsonUtil.escape(commander) + "\"}"; + try (OutputStream out = connection.getOutputStream()) { + out.write(payload.getBytes(StandardCharsets.UTF_8)); + } + + final int status = connection.getResponseCode(); + if (status == 429) { + throw new RetryAfterException(parseRetryAfter(connection.getHeaderField("Retry-After"))); + } + final InputStream responseStream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + final String body = readAll(responseStream); + if (status >= 400) { + throw new IOException("CommanderBracket API error " + status + ": " + body); + } + return CommanderBracketService.RemoteResult.fromResponse(deckHash, body); + } + finally { + connection.disconnect(); + } + } + + private static String firstCommanderName(final String decklist) { + boolean inCommanderSection = false; + for (final String rawLine : decklist.split("\\R")) { + final String line = rawLine.trim(); + if (line.equalsIgnoreCase("// Commander")) { + inCommanderSection = true; + continue; + } + if (line.startsWith("//") && inCommanderSection) { + return null; + } + if (inCommanderSection && !line.isEmpty()) { + return line.replaceFirst("^\\d+\\s+", ""); + } + } + return null; + } + + private static long parseRetryAfter(final String retryAfter) { + if (StringUtils.isBlank(retryAfter)) { + return 60000L; + } + try { + return Math.max(1L, Long.parseLong(retryAfter.trim())) * 1000L; + } + catch (final NumberFormatException e) { + return 60000L; + } + } + + private static String readAll(final InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + return sb.toString(); + } + + enum Priority { + HIGH, + LOW + } + + interface ResultHandler { + void accept(Deck deck, DeckProxy deckProxy, CommanderBracketService.RemoteResult result); + } + + private static final class ApiRequest implements Comparable { + private final Deck deck; + private final DeckProxy deckProxy; + private final String decklist; + private final String deckHash; + private final Priority priority; + private final long sequence; + + private ApiRequest(final Deck deck, final DeckProxy deckProxy, final String decklist, final String deckHash, + final Priority priority, final long sequence) { + this.deck = deck; + this.deckProxy = deckProxy; + this.decklist = decklist; + this.deckHash = deckHash; + this.priority = priority; + this.sequence = sequence; + } + + @Override + public int compareTo(final ApiRequest other) { + final int priorityCompare = priority.compareTo(other.priority); + return priorityCompare != 0 ? priorityCompare : Long.compare(sequence, other.sequence); + } + } + + private static final class RetryAfterException extends IOException { + private final long retryAfterMillis; + + private RetryAfterException(final long retryAfterMillis) { + this.retryAfterMillis = retryAfterMillis; + } + } +} diff --git a/forge-gui/src/main/java/forge/deck/CommanderBracketService.java b/forge-gui/src/main/java/forge/deck/CommanderBracketService.java new file mode 100644 index 00000000000..648756c7418 --- /dev/null +++ b/forge-gui/src/main/java/forge/deck/CommanderBracketService.java @@ -0,0 +1,488 @@ +package forge.deck; + +import forge.item.PaperCard; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; +import forge.util.JsonUtil; +import forge.util.Localizer; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public final class CommanderBracketService { + private static final Localizer localizer = Localizer.getInstance(); + private static final CommanderBracketApiClient API_CLIENT = new CommanderBracketApiClient( + CommanderBracketService::cacheRemoteResult, CommanderBracketService::fireUpdateListeners); + private static final List>> UPDATE_LISTENERS = new CopyOnWriteArrayList<>(); + + private CommanderBracketService() { + } + + public static int getBracket(final DeckProxy deck) { + if (deck != null && deck.isGeneratedDeck()) { + return 1; + } + return getResult(deck == null ? null : deck.getDeck(), deck, CommanderBracketApiClient.Priority.LOW, false).getBracket(); + } + + public static Object getBracketDisplay(final DeckProxy deck) { + if (deck != null && deck.isGeneratedDeck()) { + return ""; + } + return getResult(deck == null ? null : deck.getDeck(), deck, CommanderBracketApiClient.Priority.LOW, true).getBracketDisplay(); + } + + public static int getBestAvailableBracket(final Deck deck) { + final DeckContext context = DeckContext.create(deck); + final RemoteResult cached = context.canUseApi() ? getCachedRemoteResult(deck, context.deckHash) : null; + return cached == null ? context.getLocalResult().getBracket() : cached.bracket; + } + + public static String getExplanation(final Deck deck) { + return getResult(deck, null, CommanderBracketApiClient.Priority.HIGH, false).toExplanation(); + } + + public static String getExplanation(final Deck deck, final DeckProxy deckProxy) { + return getResult(deck, deckProxy, CommanderBracketApiClient.Priority.HIGH, false).toExplanation(); + } + + public static boolean isPending(final Deck deck) { + final DeckContext context = DeckContext.create(deck); + return context.canUseApi() && API_CLIENT.isPending(context.deckHash); + } + + public static void addUpdateListener(final Consumer listener) { + UPDATE_LISTENERS.add(new WeakReference<>(listener)); + } + + private static void fireUpdateListeners(final BracketUpdate update) { + for (final WeakReference> reference : UPDATE_LISTENERS) { + final Consumer listener = reference.get(); + if (listener == null) { + UPDATE_LISTENERS.remove(reference); + } + else { + listener.accept(update); + } + } + } + + private static Result getResult(final Deck deck, final DeckProxy deckProxy, final CommanderBracketApiClient.Priority priority, + final boolean columnDisplay) { + final DeckContext context = DeckContext.create(deck); + if (!context.canUseApi()) { + return new Result(context, null, false, !isApiEnabled()); + } + + final boolean apiEnabled = isApiEnabled(); + final RemoteResult cached = !apiEnabled || !columnDisplay + ? getCachedRemoteResult(deck, context.deckHash) + : API_CLIENT.getCachedResult(context.deckHash); + if (!apiEnabled) { + return new Result(context, cached, false, true); + } + if (cached != null) { + if (priority == CommanderBracketApiClient.Priority.HIGH && !cached.hasDetails()) { + final boolean pending = API_CLIENT.enqueue(deck, deckProxy, context.decklist, context.deckHash, priority); + return new Result(context, cached, pending, false); + } + return new Result(context, cached, false, false); + } + + if (columnDisplay && deck.getCommanderBracket() != null) { + if (!context.deckHash.equals(deck.getDeckHash())) { + API_CLIENT.enqueue(deck, deckProxy, context.decklist, context.deckHash, CommanderBracketApiClient.Priority.LOW); + } + return new Result(context, RemoteResult.fromCachedBracket(context.deckHash, deck.getCommanderBracket()), + API_CLIENT.isActive(context.deckHash), false); + } + + final boolean pending = API_CLIENT.enqueue(deck, deckProxy, context.decklist, context.deckHash, priority); + return new Result(context, null, columnDisplay ? API_CLIENT.isActive(context.deckHash) : pending, + false); + } + + private static boolean isApiEnabled() { + return FModel.getPreferences().getPrefBoolean(FPref.UI_USE_COMMANDER_BRACKET_API); + } + + private static String toCommanderBracketDecklist(final Deck deck) { + final StringBuilder sb = new StringBuilder(); + final List commanders = deck.getCommanders(); + if (!commanders.isEmpty()) { + sb.append("// Commander\n"); + for (final PaperCard commander : commanders) { + sb.append("1 ").append(commander.getName()).append("\n"); + } + sb.append("\n"); + } + + final CardPool main = deck.getMain(); + if (main == null || main.isEmpty()) { + return ""; + } + for (final Entry entry : main) { + sb.append(entry.getValue()).append(" ").append(entry.getKey().getName()).append("\n"); + } + return sb.toString().trim(); + } + + private static String hashDecklist(final String decklist) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(decklist.getBytes(StandardCharsets.UTF_8)); + final StringBuilder sb = new StringBuilder(); + for (final byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + catch (final NoSuchAlgorithmException e) { + return Integer.toHexString(decklist.hashCode()); + } + } + + private static RemoteResult getCachedRemoteResult(final Deck deck, final String deckHash) { + final RemoteResult sessionCached = API_CLIENT.getCachedResult(deckHash); + if (sessionCached != null) { + return sessionCached; + } + if (deckHash.equals(deck.getDeckHash()) && deck.getCommanderBracket() != null) { + return RemoteResult.fromCachedBracket(deckHash, deck.getCommanderBracket()); + } + return null; + } + + private static void cacheRemoteResult(final Deck deck, final DeckProxy deckProxy, final RemoteResult result) { + deck.setCommanderBracket(result.deckHash, result.bracket); + if (deckProxy != null) { + deckProxy.saveDeckMetadata(); + } + } + + private static final class DeckContext { + private final Deck deck; + private final String decklist; + private final String deckHash; + private CommanderBracketCalculator.Result localResult; + + private DeckContext(final Deck deck, final CommanderBracketCalculator.Result localResult, + final String decklist, final String deckHash) { + this.deck = deck; + this.localResult = localResult; + this.decklist = decklist; + this.deckHash = deckHash; + } + + private static DeckContext create(final Deck deck) { + if (deck == null) { + return new DeckContext(null, CommanderBracketCalculator.calculate((Deck)null), "", ""); + } + final String decklist = toCommanderBracketDecklist(deck); + return new DeckContext(deck, null, decklist, decklist.isEmpty() ? "" : hashDecklist(decklist)); + } + + private boolean canUseApi() { + return !deckHash.isEmpty(); + } + + private CommanderBracketCalculator.Result getLocalResult() { + if (localResult == null) { + localResult = CommanderBracketCalculator.calculate(deck); + } + return localResult; + } + } + + private static final class Result { + private final DeckContext context; + private final RemoteResult remoteResult; + private final boolean remotePending; + private final boolean apiDisabled; + + private Result(final DeckContext context, final RemoteResult remoteResult, + final boolean remotePending, final boolean apiDisabled) { + this.context = context; + this.remoteResult = remoteResult; + this.remotePending = remotePending; + this.apiDisabled = apiDisabled; + } + + private int getBracket() { + return remoteResult == null ? context.getLocalResult().getBracket() : remoteResult.bracket; + } + + private Object getBracketDisplay() { + return remotePending ? "..." : getBracket(); + } + + private String toExplanation() { + final StringBuilder sb = new StringBuilder(); + if (apiDisabled) { + if (remoteResult != null) { + if (remoteResult.hasDetails()) { + remoteResult.appendExplanation(sb); + } + else { + remoteResult.appendEstimate(sb); + } + sb.append("\n\n"); + } + sb.append(localizer.getMessage("lblCommanderBracketEnableApi")).append("\n\n"); + } + else if (remoteResult != null) { + remoteResult.appendExplanation(sb); + if (remotePending) { + sb.append("\n").append(localizer.getMessage("lblCommanderBracketRefreshingDetails")); + } + sb.append("\n\n"); + } + else if (remotePending) { + sb.append(localizer.getMessage("lblCommanderBracketAnalysisQueued")).append("\n\n"); + } + sb.append(context.getLocalResult().toExplanation()); + return sb.toString(); + } + } + + public static final class BracketUpdate { + private final String deckHash; + + BracketUpdate(final String deckHash) { + this.deckHash = deckHash; + } + + public String getDeckHash() { + return deckHash; + } + } + + static final class RemoteResult { + private final String deckHash; + private final int bracket; + private final String bracketName; + private final String bracketDescription; + private final String bracketReason; + private final String bracketNarrative; + private final String confidence; + private final String confidenceReason; + private final String estimatedWinTurn; + private final int totalGameChangers; + private final int fastManaCount; + private final int tutorCount; + private final int comboCount; + private final int cardsFound; + private final int totalCards; + private final String attributionLabel; + + private RemoteResult(final String deckHash, final int bracket) { + this(deckHash, bracket, "", "", "", "", "", "", "", 0, 0, 0, 0, 0, 0, + localizer.getMessage("lblCommanderBracketAttribution")); + } + + private RemoteResult(final String deckHash, final int bracket, final String bracketName, + final String bracketDescription, final String bracketReason, final String bracketNarrative, + final String confidence, final String confidenceReason, final String estimatedWinTurn, + final int totalGameChangers, final int fastManaCount, final int tutorCount, + final int comboCount, final int cardsFound, final int totalCards, + final String attributionLabel) { + this.deckHash = deckHash; + this.bracket = bracket; + this.bracketName = bracketName; + this.bracketDescription = bracketDescription; + this.bracketReason = bracketReason; + this.bracketNarrative = bracketNarrative; + this.confidence = confidence; + this.confidenceReason = confidenceReason; + this.estimatedWinTurn = estimatedWinTurn; + this.totalGameChangers = totalGameChangers; + this.fastManaCount = fastManaCount; + this.tutorCount = tutorCount; + this.comboCount = comboCount; + this.cardsFound = cardsFound; + this.totalCards = totalCards; + this.attributionLabel = attributionLabel; + } + + static RemoteResult fromResponse(final String deckHash, final String response) throws IOException { + final Object parsed = JsonUtil.parse(response); + if (!(parsed instanceof Map root)) { + throw new IOException("Unexpected CommanderBracket response."); + } + + final Map bracketAnalysis = asMap(root.get("bracket_analysis")); + final Map deckStats = asMap(root.get("deck_stats")); + final int bracket = normalizeBracket(coerceBracket(root, bracketAnalysis)); + final String winTurn = firstString(root, bracketAnalysis, "estimated_win_turn"); + + return new RemoteResult( + deckHash, + bracket, + sanitizeBracketLabel(firstString(root, bracketAnalysis, "bracket_name"), bracket), + sanitizeBracketLabel(firstString(root, bracketAnalysis, "bracket_description"), bracket), + firstString(root, bracketAnalysis, "bracket_reason"), + firstString(root, bracketAnalysis, "bracket_narrative"), + firstString(root, bracketAnalysis, "bracket_confidence"), + sanitizeBracketLabel(firstString(root, bracketAnalysis, "confidence_reason"), bracket), + winTurn, + intValue(bracketAnalysis.get("total_game_changers")), + intValue(bracketAnalysis.get("fast_mana_count")), + intValue(bracketAnalysis.get("tutor_count")), + countCombos(root), + intValue(deckStats.get("cards_found")), + intValue(deckStats.get("total_cards")), + localizer.getMessage("lblCommanderBracketAttribution")); + } + + private static RemoteResult fromCachedBracket(final String deckHash, final int bracket) { + return new RemoteResult(deckHash, bracket); + } + + private void appendExplanation(final StringBuilder sb) { + appendEstimate(sb); + if (StringUtils.isNotBlank(bracketName)) { + sb.append(" - ").append(bracketName); + } + sb.append("\n"); + appendLine(sb, localizer.getMessage("lblCommanderBracketDescriptionLabel"), bracketDescription); + appendLine(sb, localizer.getMessage("lblCommanderBracketReasonLabel"), bracketReason); + appendLine(sb, localizer.getMessage("lblCommanderBracketNarrativeLabel"), bracketNarrative); + appendLine(sb, localizer.getMessage("lblCommanderBracketEstimatedWinTurnLabel"), estimatedWinTurn); + appendLine(sb, localizer.getMessage("lblCommanderBracketConfidenceLabel"), confidence); + appendLine(sb, localizer.getMessage("lblCommanderBracketConfidenceReasonLabel"), confidenceReason); + if (cardsFound > 0 || totalCards > 0) { + sb.append(localizer.getMessage("lblCommanderBracketCardsFound", cardsFound, totalCards)).append("\n"); + } + if (hasSignalDetails()) { + sb.append(localizer.getMessage("lblCommanderBracketSignals", + totalGameChangers, fastManaCount, tutorCount, comboCount)).append("\n\n"); + } + sb.append(attributionLabel); + } + + private void appendEstimate(final StringBuilder sb) { + sb.append(localizer.getMessage("lblCommanderBracketAppEstimate", bracket)); + } + + private boolean hasSignalDetails() { + return totalGameChangers > 0 || fastManaCount > 0 || tutorCount > 0 || comboCount > 0; + } + + private boolean hasDetails() { + return StringUtils.isNotBlank(bracketName) + || StringUtils.isNotBlank(bracketDescription) + || StringUtils.isNotBlank(bracketReason) + || StringUtils.isNotBlank(bracketNarrative) + || StringUtils.isNotBlank(confidence) + || StringUtils.isNotBlank(confidenceReason) + || StringUtils.isNotBlank(estimatedWinTurn) + || cardsFound > 0 + || totalCards > 0 + || hasSignalDetails(); + } + + private static void appendLine(final StringBuilder sb, final String label, final String value) { + if (StringUtils.isNotBlank(value)) { + sb.append(label).append(": ").append(value).append("\n"); + } + } + } + + private static Map asMap(final Object value) { + return value instanceof Map map ? map : Collections.emptyMap(); + } + + private static String firstString(final Map root, final Map nested, final String key) { + final String fromRoot = stringValue(root.get(key)); + return StringUtils.isNotBlank(fromRoot) ? fromRoot : stringValue(nested.get(key)); + } + + private static int firstInt(final Map values, final String... keys) { + for (final String key : keys) { + final int value = intValue(values.get(key)); + if (value > 0) { + return value; + } + } + return 0; + } + + private static int coerceBracket(final Map root, final Map bracketAnalysis) { + final int nestedEstimate = firstInt(bracketAnalysis, "final_bracket", "bracket", "overall_bracket", "estimated_bracket"); + if (nestedEstimate > 0) { + return nestedEstimate; + } + final int rootEstimate = firstInt(root, "final_bracket", "bracket", "overall_bracket", "estimated_bracket"); + if (rootEstimate > 0) { + return rootEstimate; + } + final int rootDeckBracket = intValue(root.get("deck_bracket")); + if (rootDeckBracket > 0) { + return rootDeckBracket; + } + return intValue(bracketAnalysis.get("deck_bracket")); + } + + private static int normalizeBracket(final int bracket) { + return Math.max(1, Math.min(5, bracket)); + } + + private static String sanitizeBracketLabel(final String value, final int bracket) { + final int labeledBracket = findLabeledBracket(value); + return labeledBracket > 0 && labeledBracket != bracket ? "" : value; + } + + private static int findLabeledBracket(final String value) { + if (StringUtils.isBlank(value)) { + return 0; + } + final String lowerValue = value.toLowerCase(Locale.ROOT); + for (int i = 1; i <= 5; i++) { + if (lowerValue.contains("bracket " + i)) { + return i; + } + } + return 0; + } + + private static int countCombos(final Map root) { + final Object combos = asMap(asMap(root.get("ipom_analysis")).get("combos")).get("detected_combos"); + return combos instanceof List list ? list.size() : 0; + } + + private static String stringValue(final Object value) { + if (value == null) { + return ""; + } + if (value instanceof Number number) { + return number.toString(); + } + return String.valueOf(value); + } + + private static int intValue(final Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text) { + try { + return Integer.parseInt(text.trim()); + } + catch (final NumberFormatException ignored) { + } + } + return 0; + } + +} diff --git a/forge-gui/src/main/java/forge/deck/DeckProxy.java b/forge-gui/src/main/java/forge/deck/DeckProxy.java index c8c8392929a..2d353062d5b 100644 --- a/forge-gui/src/main/java/forge/deck/DeckProxy.java +++ b/forge-gui/src/main/java/forge/deck/DeckProxy.java @@ -83,6 +83,20 @@ public Deck getDeck() { return deck instanceof Deck && fnGetDeck == null ? (Deck) deck : fnGetDeck.apply(deck); } + public void saveDeckMetadata() { + if (storage == null || !(deck instanceof Deck)) { + return; + } + try { + @SuppressWarnings("unchecked") + final IStorage writableStorage = (IStorage) storage; + writableStorage.add(deck); + } + catch (final RuntimeException ignored) { + // Some deck sources are read-only; the in-memory tag still avoids duplicate requests this session. + } + } + public String getPath() { return path; } diff --git a/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java b/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java index 81e9c752e97..f5662592ecc 100644 --- a/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java +++ b/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java @@ -42,7 +42,7 @@ import java.util.Set; import java.util.function.Function; -import forge.deck.CommanderBracketCalculator; +import forge.deck.CommanderBracketService; public enum ColumnDef { /** @@ -327,11 +327,11 @@ public enum ColumnDef { DECK_BRACKET("lblBracket", "ttCommanderBracket", 55, true, SortState.ASC, from -> { DeckProxy deck = toDeck(from.getKey()); - return deck == null ? 1 : CommanderBracketCalculator.getBracket(deck.getDeck()); + return deck == null ? 1 : CommanderBracketService.getBracket(deck); }, from -> { DeckProxy deck = toDeck(from.getKey()); - return deck == null ? "" : CommanderBracketCalculator.getBracket(deck.getDeck()); + return deck == null ? "" : CommanderBracketService.getBracketDisplay(deck); }), /** * The main library size column. 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 5725898f1d3..1dfdcbd18eb 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -157,6 +157,7 @@ public enum FPref implements PreferencesStore.IPref { UI_HAND_NO_OVERLAP("false"), UI_ZONE_TAB_NEW_COUNT("true"), UI_ENABLE_AI_PICKER("false"), + UI_USE_COMMANDER_BRACKET_API("false"), UI_GROUP_IDENTICAL_CARDS("false"), UI_ENABLE_SOUNDS ("true"), From c27f8b6f11f93067531bfb668c9e8e1365a5e103 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Wed, 10 Jun 2026 09:38:10 -0700 Subject: [PATCH 2/3] Don't modify precon decks --- .../main/java/forge/deck/io/DeckStorage.java | 55 ++++++++++++++++++- .../forge/deck/CommanderBracketService.java | 28 +++++++++- .../src/main/java/forge/deck/DeckProxy.java | 20 ++++--- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/forge-core/src/main/java/forge/deck/io/DeckStorage.java b/forge-core/src/main/java/forge/deck/io/DeckStorage.java index a6aa18fff37..d9464824b73 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckStorage.java +++ b/forge-core/src/main/java/forge/deck/io/DeckStorage.java @@ -24,9 +24,11 @@ import forge.util.IItemReader; import forge.util.IItemSerializer; import forge.util.storage.StorageReaderFolder; +import org.apache.commons.lang3.StringUtils; import java.io.File; import java.io.FilenameFilter; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -76,6 +78,45 @@ public File makeFileFor(final Deck deck) { return new File(this.directory, deck.getBestFileName() + FILE_EXTENSION); } + public boolean saveMetadata(final Deck deck) { + final File file = makeFileFor(deck); + if (!file.exists()) { + return false; + } + try { + final List lines = FileUtil.readFile(file); + final int metadataStart = findMetadataSection(lines); + if (metadataStart < 0) { + return false; + } + + final List updatedLines = new ArrayList<>(lines); + int insertAt = metadataStart + 1; + for (int i = metadataStart + 1; i < updatedLines.size(); i++) { + final String line = updatedLines.get(i).trim(); + if (line.startsWith("[") && line.endsWith("]")) { + break; + } + if (isMetadataLine(line, DeckFileHeader.DECK_HASH) || isMetadataLine(line, DeckFileHeader.COMMANDER_BRACKET)) { + updatedLines.remove(i--); + continue; + } + if (isMetadataLine(line, DeckFileHeader.NAME) || isMetadataLine(line, DeckFileHeader.COMMENT)) { + insertAt = i + 1; + } + } + if (StringUtils.isNotBlank(deck.getDeckHash()) && deck.getCommanderBracket() != null) { + updatedLines.add(insertAt, DeckFileHeader.DECK_HASH + "=" + deck.getDeckHash()); + updatedLines.add(insertAt + 1, DeckFileHeader.COMMANDER_BRACKET + "=" + deck.getCommanderBracket()); + } + FileUtil.writeFile(file, updatedLines); + return true; + } + catch (final RuntimeException ignored) { + return false; + } + } + @Override protected Deck read(final File file) { final Map> sections = FileSection.parseSections(FileUtil.readFile(file)); @@ -106,5 +147,17 @@ private static void adjustFileLocation(final File file, final Deck result) { protected FilenameFilter getFileFilter() { return DCK_FILE_FILTER; } -} + private static int findMetadataSection(final List lines) { + for (int i = 0; i < lines.size(); i++) { + if ("[metadata]".equalsIgnoreCase(lines.get(i).trim())) { + return i; + } + } + return -1; + } + + private static boolean isMetadataLine(final String line, final String key) { + return line.regionMatches(true, 0, key + "=", 0, key.length() + 1); + } +} diff --git a/forge-gui/src/main/java/forge/deck/CommanderBracketService.java b/forge-gui/src/main/java/forge/deck/CommanderBracketService.java index 648756c7418..6c82f7ce717 100644 --- a/forge-gui/src/main/java/forge/deck/CommanderBracketService.java +++ b/forge-gui/src/main/java/forge/deck/CommanderBracketService.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -33,6 +34,9 @@ public static int getBracket(final DeckProxy deck) { if (deck != null && deck.isGeneratedDeck()) { return 1; } + if (deck != null && deck.isPreconstructedDeck()) { + return CommanderBracketCalculator.getBracket(deck.getDeck()); + } return getResult(deck == null ? null : deck.getDeck(), deck, CommanderBracketApiClient.Priority.LOW, false).getBracket(); } @@ -40,6 +44,9 @@ public static Object getBracketDisplay(final DeckProxy deck) { if (deck != null && deck.isGeneratedDeck()) { return ""; } + if (deck != null && deck.isPreconstructedDeck()) { + return CommanderBracketCalculator.getBracket(deck.getDeck()); + } return getResult(deck == null ? null : deck.getDeck(), deck, CommanderBracketApiClient.Priority.LOW, true).getBracketDisplay(); } @@ -138,6 +145,21 @@ private static String toCommanderBracketDecklist(final Deck deck) { return sb.toString().trim(); } + private static String toCanonicalCommanderBracketDecklist(final Deck deck) { + final List lines = new ArrayList<>(); + for (final PaperCard commander : deck.getCommanders()) { + lines.add("C 1 " + commander.getName()); + } + final CardPool main = deck.getMain(); + if (main != null) { + for (final Entry entry : main) { + lines.add("M " + entry.getValue() + " " + entry.getKey().getName()); + } + } + lines.sort(String.CASE_INSENSITIVE_ORDER); + return String.join("\n", lines); + } + private static String hashDecklist(final String decklist) { try { final MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -165,6 +187,9 @@ private static RemoteResult getCachedRemoteResult(final Deck deck, final String } private static void cacheRemoteResult(final Deck deck, final DeckProxy deckProxy, final RemoteResult result) { + if (deckProxy != null && !deckProxy.canSaveDeckMetadata()) { + return; + } deck.setCommanderBracket(result.deckHash, result.bracket); if (deckProxy != null) { deckProxy.saveDeckMetadata(); @@ -190,7 +215,8 @@ private static DeckContext create(final Deck deck) { return new DeckContext(null, CommanderBracketCalculator.calculate((Deck)null), "", ""); } final String decklist = toCommanderBracketDecklist(deck); - return new DeckContext(deck, null, decklist, decklist.isEmpty() ? "" : hashDecklist(decklist)); + return new DeckContext(deck, null, decklist, + decklist.isEmpty() ? "" : hashDecklist(toCanonicalCommanderBracketDecklist(deck))); } private boolean canUseApi() { diff --git a/forge-gui/src/main/java/forge/deck/DeckProxy.java b/forge-gui/src/main/java/forge/deck/DeckProxy.java index 2d353062d5b..134a96a6b5a 100644 --- a/forge-gui/src/main/java/forge/deck/DeckProxy.java +++ b/forge-gui/src/main/java/forge/deck/DeckProxy.java @@ -4,6 +4,7 @@ import forge.card.*; import forge.card.mana.ManaCostShard; import forge.deck.io.DeckPreferences; +import forge.deck.io.DeckStorage; import forge.game.GameFormat; import forge.game.GameType; import forge.gamemodes.quest.QuestController; @@ -84,19 +85,22 @@ public Deck getDeck() { } public void saveDeckMetadata() { - if (storage == null || !(deck instanceof Deck)) { + if (!canSaveDeckMetadata()) { return; } - try { - @SuppressWarnings("unchecked") - final IStorage writableStorage = (IStorage) storage; - writableStorage.add(deck); - } - catch (final RuntimeException ignored) { - // Some deck sources are read-only; the in-memory tag still avoids duplicate requests this session. + if (storage instanceof DeckStorage deckStorage) { + deckStorage.saveMetadata((Deck) deck); } } + public boolean canSaveDeckMetadata() { + return storage != null && deck instanceof Deck && !isGeneratedDeck() && !isPreconstructedDeck(); + } + + public boolean isPreconstructedDeck() { + return deck instanceof PreconDeck || "Precon".equals(deckType) || "Commander Precon".equals(deckType); + } + public String getPath() { return path; } From 6ecb6d8e94cdab0fc4f61e8f74be0077250647bd Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Wed, 10 Jun 2026 09:58:16 -0700 Subject: [PATCH 3/3] Fix metadata storage --- .../src/main/java/forge/deck/io/DeckStorage.java | 15 +++++++-------- .../src/main/java/forge/util/IItemSerializer.java | 2 ++ .../main/java/forge/util/storage/IStorage.java | 3 ++- .../storage/StorageImmediatelySerialized.java | 5 +++++ forge-gui/src/main/java/forge/deck/DeckProxy.java | 7 +++---- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/forge-core/src/main/java/forge/deck/io/DeckStorage.java b/forge-core/src/main/java/forge/deck/io/DeckStorage.java index d9464824b73..b50e8f402a1 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckStorage.java +++ b/forge-core/src/main/java/forge/deck/io/DeckStorage.java @@ -28,7 +28,6 @@ import java.io.File; import java.io.FilenameFilter; -import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -78,6 +77,7 @@ public File makeFileFor(final Deck deck) { return new File(this.directory, deck.getBestFileName() + FILE_EXTENSION); } + @Override public boolean saveMetadata(final Deck deck) { final File file = makeFileFor(deck); if (!file.exists()) { @@ -90,15 +90,14 @@ public boolean saveMetadata(final Deck deck) { return false; } - final List updatedLines = new ArrayList<>(lines); int insertAt = metadataStart + 1; - for (int i = metadataStart + 1; i < updatedLines.size(); i++) { - final String line = updatedLines.get(i).trim(); + for (int i = metadataStart + 1; i < lines.size(); i++) { + final String line = lines.get(i).trim(); if (line.startsWith("[") && line.endsWith("]")) { break; } if (isMetadataLine(line, DeckFileHeader.DECK_HASH) || isMetadataLine(line, DeckFileHeader.COMMANDER_BRACKET)) { - updatedLines.remove(i--); + lines.remove(i--); continue; } if (isMetadataLine(line, DeckFileHeader.NAME) || isMetadataLine(line, DeckFileHeader.COMMENT)) { @@ -106,10 +105,10 @@ public boolean saveMetadata(final Deck deck) { } } if (StringUtils.isNotBlank(deck.getDeckHash()) && deck.getCommanderBracket() != null) { - updatedLines.add(insertAt, DeckFileHeader.DECK_HASH + "=" + deck.getDeckHash()); - updatedLines.add(insertAt + 1, DeckFileHeader.COMMANDER_BRACKET + "=" + deck.getCommanderBracket()); + lines.add(insertAt, DeckFileHeader.DECK_HASH + "=" + deck.getDeckHash()); + lines.add(insertAt + 1, DeckFileHeader.COMMANDER_BRACKET + "=" + deck.getCommanderBracket()); } - FileUtil.writeFile(file, updatedLines); + FileUtil.writeFile(file, lines); return true; } catch (final RuntimeException ignored) { diff --git a/forge-core/src/main/java/forge/util/IItemSerializer.java b/forge-core/src/main/java/forge/util/IItemSerializer.java index 22f498d549d..8b7bf5eec89 100644 --- a/forge-core/src/main/java/forge/util/IItemSerializer.java +++ b/forge-core/src/main/java/forge/util/IItemSerializer.java @@ -33,6 +33,8 @@ public interface IItemSerializer extends IItemReader { */ void save(T unit); + default boolean saveMetadata(T unit) { return false; } + /** * Erase. * diff --git a/forge-core/src/main/java/forge/util/storage/IStorage.java b/forge-core/src/main/java/forge/util/storage/IStorage.java index a93c6174444..b20f6ea675c 100644 --- a/forge-core/src/main/java/forge/util/storage/IStorage.java +++ b/forge-core/src/main/java/forge/util/storage/IStorage.java @@ -32,9 +32,10 @@ public interface IStorage extends Iterable, IHasName { int size(); void add(T item); void add(String name, T item); + default boolean saveMetadata(T item) { return false; } void delete(String deckName); IStorage> getFolders(); IStorage tryGetFolder(String path); IStorage getFolderOrCreate(String path); Stream stream(); -} \ No newline at end of file +} diff --git a/forge-core/src/main/java/forge/util/storage/StorageImmediatelySerialized.java b/forge-core/src/main/java/forge/util/storage/StorageImmediatelySerialized.java index c6ddc7d2941..c0e0365a0f3 100644 --- a/forge-core/src/main/java/forge/util/storage/StorageImmediatelySerialized.java +++ b/forge-core/src/main/java/forge/util/storage/StorageImmediatelySerialized.java @@ -72,6 +72,11 @@ public final void add(final T item) { this.serializer.save(item); } + @Override + public boolean saveMetadata(final T item) { + return serializer.saveMetadata(item); + } + /* * (non-Javadoc) * diff --git a/forge-gui/src/main/java/forge/deck/DeckProxy.java b/forge-gui/src/main/java/forge/deck/DeckProxy.java index 134a96a6b5a..64820324c9a 100644 --- a/forge-gui/src/main/java/forge/deck/DeckProxy.java +++ b/forge-gui/src/main/java/forge/deck/DeckProxy.java @@ -4,7 +4,6 @@ import forge.card.*; import forge.card.mana.ManaCostShard; import forge.deck.io.DeckPreferences; -import forge.deck.io.DeckStorage; import forge.game.GameFormat; import forge.game.GameType; import forge.gamemodes.quest.QuestController; @@ -88,9 +87,9 @@ public void saveDeckMetadata() { if (!canSaveDeckMetadata()) { return; } - if (storage instanceof DeckStorage deckStorage) { - deckStorage.saveMetadata((Deck) deck); - } + @SuppressWarnings("unchecked") + final IStorage writableStorage = (IStorage) storage; + writableStorage.saveMetadata(deck); } public boolean canSaveDeckMetadata() {