diff --git a/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java b/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java index 11897b53557..2a284d65cde 100644 --- a/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java +++ b/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java @@ -286,6 +286,10 @@ private void updateNetArchiveBlockDecks() { updateDecks(DeckProxy.getNetArchiveBlockDecks(NetDeckArchiveBlock), ItemManagerConfig.NET_DECKS); } + private void updateNetEventDecks() { + updateDecks(DeckProxy.getAllNetworkEventDecks(), ItemManagerConfig.NET_EVENT_DECKS); + } + public Deck getDeck() { final DeckProxy proxy = lstDecks.getSelectedItem(); if (proxy == null) { @@ -647,6 +651,9 @@ private void refreshDecksList(final DeckType deckType, final boolean forceRefres case NET_ARCHIVE_BLOCK_DECK: updateNetArchiveBlockDecks(); break; + case NET_EVENT_DECK: + updateNetEventDecks(); + break; default: break; //other deck types not currently supported here } diff --git a/forge-gui-desktop/src/main/java/forge/gui/FDraftOverlay.java b/forge-gui-desktop/src/main/java/forge/gui/FDraftOverlay.java new file mode 100644 index 00000000000..8a629466ba4 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/gui/FDraftOverlay.java @@ -0,0 +1,403 @@ +package forge.gui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.event.MouseEvent; + +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +import forge.Singletons; +import forge.screens.home.online.OnlineMenu; +import forge.toolbox.FMouseAdapter; +import forge.toolbox.FSkin; +import forge.util.Localizer; +import forge.view.FDialog; +import forge.view.FFrame; +import net.miginfocom.swing.MigLayout; + +/** + * Floating overlay window displayed during network draft sessions. + * Shows pack/round info, a countdown timer, and neighbor seats with queue depths. + * + * Follows the same FDialog pattern as {@link FNetOverlay}. + */ +public enum FDraftOverlay { + SINGLETON_INSTANCE; + + private static final int DEFAULT_WIDTH = 420; + private static final int DEFAULT_HEIGHT = 95; + + private boolean hasBeenShown; + + private final FSkin.SkinnedLabel lblPackInfo = new FSkin.SkinnedLabel(""); + private final FSkin.SkinnedLabel lblTimer = new FSkin.SkinnedLabel(""); + private final JPanel pnlNeighbors = new JPanel(new FlowLayout(FlowLayout.CENTER, 4, 0)); + + private static ImageIcon cardBackIcon; + + private String leftName, rightName; + private boolean leftAI, rightAI; + private int mySeat; + private int currentPack, totalPacks; + private int currentPick, initialPackSize; + private boolean passingRight; + private int[] queueDepths = new int[0]; + + /** Countdown timer (client-side fire-and-forget). */ + private Timer countdownTimer; + private int secondsRemaining; + private boolean waitingForPack; + + private final FDialog window = new FDialog(false, true, "4"); + + FDraftOverlay() { + window.setTitle(Localizer.getInstance().getMessage("lblDraft")); + window.setVisible(false); + window.setBackground(FSkin.getColor(FSkin.Colors.CLR_ZEBRA)); + window.setBorder(new FSkin.LineSkinBorder(FSkin.getColor(FSkin.Colors.CLR_BORDERS))); + + // Two-row layout: [pack info | timer] then [neighbor strip spanning both columns]; + // second row grows to fill remaining vertical space so the neighbor strip + // centers between the info row and the bottom of the window + window.setLayout(new MigLayout("insets 4, gap 0, wrap 2", "", "[][grow]")); + + // Apply bold skin font and text color to all labels + FSkin.SkinColor textColor = FSkin.getColor(FSkin.Colors.CLR_TEXT); + + lblPackInfo.setFont(FSkin.getBoldFont(14)); + lblTimer.setFont(FSkin.getBoldFont(14)); + + if (textColor != null) { + lblPackInfo.setForeground(textColor); + lblTimer.setForeground(textColor); + } + + lblPackInfo.setHorizontalAlignment(SwingConstants.LEFT); + lblTimer.setHorizontalAlignment(SwingConstants.RIGHT); + + lblPackInfo.setOpaque(false); + lblTimer.setOpaque(false); + pnlNeighbors.setOpaque(false); + + // Row 1: pack info on the left, timer on the right + window.add(lblPackInfo, "pushx, growx, gapleft 4"); + window.add(lblTimer, "pushx, growx, gapright 4, al right"); + // Row 2: neighbor strip spans both columns, vertically centered in its cell + window.add(pnlNeighbors, "span 2, pushx, growx, gapleft 4, gapright 4, ay center"); + + // Load card back icon (scaled to small size) + loadCardBackIcon(); + } + + /** + * Called at draft start. + * + * @param mySeat this player's seat index (0-based) + * @param names display names for all seats in pod order + * @param aiFlags parallel boolean array — true if that seat is AI + * @param totalPacks total number of packs in the draft + */ + public void initDraft(int mySeat, String[] names, boolean[] aiFlags, int totalPacks) { + SwingUtilities.invokeLater(() -> { + if (names.length == 0) { + // Stale pre-participants event view — skip; a later call with + // populated participants will reinitialize properly + return; + } + this.mySeat = mySeat; + this.totalPacks = totalPacks; + // At draft start each seat holds exactly one pack (the one they're + // picking from); the server's subsequent SeatPicked broadcasts overwrite + // these as picks are made, but showing 1s up front gives every seat a + // visible pack indicator on pack 1 / pick 1 + this.queueDepths = new int[names.length]; + for (int i = 0; i < queueDepths.length; i++) queueDepths[i] = 1; + + int podSize = names.length; + int leftIdx = (mySeat - 1 + podSize) % podSize; + int rightIdx = (mySeat + 1) % podSize; + + leftName = names[leftIdx]; + rightName = names[rightIdx]; + leftAI = aiFlags[leftIdx]; + rightAI = aiFlags[rightIdx]; + + // Only show "waiting" if no pack has arrived yet — otherwise preserve + // state set by onPackArrived() which may have run before us + if (currentPack == 0) { + waitingForPack = true; + } + updateDisplay(); + show(); + }); + } + + /** + * Called when a new pack arrives for this player to pick from. + * + * @param packNumber 1-based pack number + * @param pickNumber 0-based pick number within the pack round + * @param packSize number of cards in the pack + * @param timerSeconds seconds allowed to pick (0 = no timer) + */ + public void onPackArrived(int packNumber, int pickNumber, int packSize, int timerSeconds) { + SwingUtilities.invokeLater(() -> { + currentPack = packNumber; + currentPick = pickNumber + 1; + if (pickNumber == 0) { + initialPackSize = packSize; + } + waitingForPack = false; + // Pack direction: odd packs pass right, even packs pass left (conventional booster draft) + passingRight = (packNumber % 2 == 1); + updateDisplay(); + if (timerSeconds > 0) { + startCountdown(timerSeconds); + } + }); + } + + /** + * Called when any seat in the pod picks a card. + * + * @param newDepths updated queue-depth array (one entry per seat) + */ + public void onSeatPicked(int[] newDepths) { + SwingUtilities.invokeLater(() -> { + if (newDepths != null && newDepths.length == queueDepths.length) { + System.arraycopy(newDepths, 0, queueDepths, 0, newDepths.length); + } + updateDisplay(); + }); + } + + /** + * Called when THIS player submits a pick. + * Stops the countdown and shows "Waiting for packs..." until the next pack arrives. + */ + public void onPickSubmitted() { + SwingUtilities.invokeLater(() -> { + stopCountdown(); + waitingForPack = true; + updateDisplay(); + }); + } + + /** Hides the overlay and clears draft state. Call at draft end. */ + public void reset() { + SwingUtilities.invokeLater(() -> { + stopCountdown(); + leftName = rightName = null; + leftAI = rightAI = false; + mySeat = 0; + currentPack = totalPacks = 0; + passingRight = false; + queueDepths = new int[0]; + waitingForPack = false; + lblPackInfo.setText(""); + lblTimer.setText(""); + pnlNeighbors.removeAll(); + hide(); + }); + } + + public void hide() { + window.setVisible(false); + OnlineMenu.draftItem.setState(false); + } + + public void show() { + if (!hasBeenShown) { + FFrame mainFrame = Singletons.getView().getFrame(); + window.setBounds(mainFrame.getX() + 10, mainFrame.getY() + 50, DEFAULT_WIDTH, DEFAULT_HEIGHT); + window.getTitleBar().addMouseListener(new FMouseAdapter() { + @Override + public void onLeftDoubleClick(MouseEvent e) { + hide(); + } + }); + hasBeenShown = true; + } + window.setVisible(true); + OnlineMenu.draftItem.setState(true); + } + + private void updateDisplay() { + final Localizer localizer = Localizer.getInstance(); + // Row 1 – pack info + pick number + if (currentPack > 0 && totalPacks > 0) { + String text = localizer.getMessage("lblDraftOverlayPackOfN", + String.valueOf(currentPack), String.valueOf(totalPacks)); + if (currentPick > 0 && initialPackSize > 0) { + text += " \u2022 " + localizer.getMessage("lblDraftOverlayPickOfN", + String.valueOf(currentPick), String.valueOf(initialPackSize)); + } + lblPackInfo.setText(text); + } else { + lblPackInfo.setText(localizer.getMessage("lblDraft")); + } + + // Row 1 – timer + if (waitingForPack) { + lblTimer.setText(localizer.getMessage("lblDraftOverlayWaitingForPack")); + lblTimer.setForeground(FSkin.getColor(FSkin.Colors.CLR_TEXT)); + } else if (countdownTimer != null && countdownTimer.isRunning()) { + updateTimerLabel(); + } else { + lblTimer.setText(""); + } + + // Row 2 – neighbor strip + if (leftName != null && rightName != null) { + buildNeighborPanel(); + } + + window.revalidate(); + window.repaint(); + } + + private void startCountdown(int seconds) { + stopCountdown(); + secondsRemaining = seconds; + updateTimerLabel(); + countdownTimer = new Timer(1000, e -> { + secondsRemaining--; + if (secondsRemaining <= 0) { + secondsRemaining = 0; + stopCountdown(); + } + updateTimerLabel(); + }); + countdownTimer.start(); + } + + private void stopCountdown() { + if (countdownTimer != null) { + countdownTimer.stop(); + countdownTimer = null; + } + } + + private void updateTimerLabel() { + int mins = secondsRemaining / 60; + int secs = secondsRemaining % 60; + lblTimer.setText(Localizer.getInstance().getMessage("lblDraftOverlayTimer", + String.format("%d:%02d", mins, secs))); + + // Color based on urgency + Color timerColor; + if (secondsRemaining <= 5) { + timerColor = Color.RED; + } else if (secondsRemaining <= 15) { + timerColor = Color.YELLOW; + } else { + FSkin.SkinColor skinText = FSkin.getColor(FSkin.Colors.CLR_TEXT); + timerColor = (skinText != null) ? skinText.getColor() : Color.WHITE; + } + lblTimer.setForeground(timerColor); + } + + /** + * Builds the neighbor display panel with text labels and card-back icons. + * + * Layout (passing right / odd packs — packs flow left→right, so they arrive + * on the LEFT side of each seat): + * [packs]LeftName → [packs]YOU → [packs]RightName + * + * Layout (passing left / even packs — packs flow right→left, so they arrive + * on the RIGHT side of each seat): + * LeftName[packs] ← YOU[packs] ← RightName[packs] + * + * Icons always sit on the incoming side of their seat — the side the next + * pack will arrive from. + */ + private void buildNeighborPanel() { + pnlNeighbors.removeAll(); + + final Localizer localizer = Localizer.getInstance(); + int podSize = queueDepths.length; + int leftIdx = podSize > 0 ? (mySeat - 1 + podSize) % podSize : 0; + int rightIdx = podSize > 0 ? (mySeat + 1) % podSize : 0; + + int myDepth = podSize > 0 ? queueDepths[mySeat] : 0; + int leftDepth = podSize > 0 ? queueDepths[leftIdx] : 0; + int rightDepth = podSize > 0 ? queueDepths[rightIdx] : 0; + + String aiSuffix = " (" + localizer.getMessage("lblAI") + ")"; + String leftLabel = leftName + (leftAI ? aiSuffix : ""); + String rightLabel = rightName + (rightAI ? aiSuffix : ""); + String arrow = passingRight ? " \u2192 " : " \u2190 "; + String you = localizer.getMessage("lblDraftOverlayYou"); + + if (passingRight) { + // Incoming side is the LEFT of each name — icons before the name + addPackIcons(leftDepth); + pnlNeighbors.add(makeTextLabel(leftLabel + arrow)); + addPackIcons(myDepth); + pnlNeighbors.add(makeTextLabel(you + arrow)); + addPackIcons(rightDepth); + pnlNeighbors.add(makeTextLabel(rightLabel)); + } else { + // Incoming side is the RIGHT of each name — icons after the name + pnlNeighbors.add(makeTextLabel(leftLabel)); + addPackIcons(leftDepth); + pnlNeighbors.add(makeTextLabel(arrow + you)); + addPackIcons(myDepth); + pnlNeighbors.add(makeTextLabel(arrow + rightLabel)); + addPackIcons(rightDepth); + } + + pnlNeighbors.revalidate(); + pnlNeighbors.repaint(); + } + + private static void loadCardBackIcon() { + if (cardBackIcon != null) return; + try { + FSkin.SkinImage sleeve = FSkin.getSleeves().get(0); + if (sleeve != null) { + cardBackIcon = sleeve.resize(18, 25).getIcon(); + } + } catch (Exception e) { + // Fallback: icon stays null, text "[P]" will be used + } + } + + private void addPackIcons(int depth) { + if (depth <= 0) return; + if (cardBackIcon != null) { + JLabel icon = new JLabel(cardBackIcon); + icon.setOpaque(false); + pnlNeighbors.add(icon); + } else { + pnlNeighbors.add(makeTextLabel("[P]")); + } + if (depth > 1) { + FSkin.SkinnedLabel plus = new FSkin.SkinnedLabel("x" + depth); + plus.setFont(FSkin.getBoldFont(12)); + FSkin.SkinColor color = FSkin.getColor(FSkin.Colors.CLR_TEXT); + if (color != null) plus.setForeground(color); + plus.setOpaque(false); + plus.setVerticalAlignment(SwingConstants.TOP); + Dimension pref = plus.getPreferredSize(); + plus.setPreferredSize(new Dimension(pref.width, 25)); + pnlNeighbors.add(plus); + } + } + + private JLabel makeTextLabel(String text) { + FSkin.SkinnedLabel lbl = new FSkin.SkinnedLabel(text); + lbl.setFont(FSkin.getBoldFont(14)); + FSkin.SkinColor color = FSkin.getColor(FSkin.Colors.CLR_TEXT); + if (color != null) lbl.setForeground(color); + lbl.setOpaque(false); + return lbl; + } + +} diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java index dabd0d5968e..57c2a426341 100644 --- a/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java +++ b/forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java @@ -11,6 +11,7 @@ import forge.game.zone.ZoneType; import forge.screens.deckeditor.views.*; import forge.screens.home.gauntlet.*; +import forge.screens.home.online.VSubmenuOnlineDecks; import forge.screens.home.online.VSubmenuOnlineLobby; import forge.screens.home.puzzle.VSubmenuPuzzleCreate; import forge.screens.home.puzzle.VSubmenuPuzzleSolve; @@ -87,6 +88,7 @@ public enum EDocID { HOME_SEALED (VSubmenuSealed.SINGLETON_INSTANCE), HOME_WINSTON (VSubmenuWinston.SINGLETON_INSTANCE), HOME_NETWORK (VSubmenuOnlineLobby.SINGLETON_INSTANCE), + HOME_NET_DECKS (VSubmenuOnlineDecks.SINGLETON_INSTANCE), HOME_RELEASE_NOTES (VSubmenuReleaseNotes.SINGLETON_INSTANCE), REPORT_MESSAGE (), 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 94e7597325b..0bd0adeaf49 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/DeckManager.java @@ -19,6 +19,7 @@ import forge.Singletons; import forge.deck.Deck; import forge.deck.DeckBase; +import forge.deck.DeckGroup; import forge.deck.DeckProxy; import forge.deck.io.DeckPreferences; import forge.game.GameFormat; @@ -350,15 +351,15 @@ public void editDeck(final DeckProxy deck) { break; case Sealed: screen = FScreen.DECK_EDITOR_SEALED; - editorCtrl = new CEditorLimited(FModel.getDecks().getSealed(), screen, getCDetailPicture()); + editorCtrl = new CEditorLimited<>(FModel.getDecks().getSealed(), DeckGroup::new, screen, getCDetailPicture()); break; case Draft: screen = FScreen.DECK_EDITOR_DRAFT; - editorCtrl = new CEditorLimited(FModel.getDecks().getDraft(), screen, getCDetailPicture()); + editorCtrl = new CEditorLimited<>(FModel.getDecks().getDraft(), DeckGroup::new, screen, getCDetailPicture()); break; case Winston: screen = FScreen.DECK_EDITOR_DRAFT; - editorCtrl = new CEditorLimited(FModel.getDecks().getWinston(), screen, getCDetailPicture()); + editorCtrl = new CEditorLimited<>(FModel.getDecks().getWinston(), DeckGroup::new, screen, getCDetailPicture()); break; default: diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/CDeckEditorUI.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/CDeckEditorUI.java index 666f3a5faef..204b2a3114d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/CDeckEditorUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/CDeckEditorUI.java @@ -36,6 +36,9 @@ import forge.itemmanager.ItemManager; import forge.screens.deckeditor.controllers.ACEditorBase; import forge.screens.deckeditor.controllers.CEditorConstructed; +import forge.screens.deckeditor.controllers.CEditorDraftingProcess; +import forge.screens.deckeditor.controllers.CEditorLimited; +import forge.screens.deckeditor.controllers.CEditorNetworkDraft; import forge.screens.deckeditor.controllers.CEditorQuestCardShop; import forge.screens.deckeditor.controllers.CProbabilities; import forge.screens.deckeditor.controllers.CStatistics; @@ -214,6 +217,14 @@ private void setCurrentEditorController(final ACEditorBase catView = childController.getCatalogManager(); final ItemManager deckView = childController.getDeckManager(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorDraftingProcess.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorDraftingProcess.java index c6856ea7f53..14b7820d929 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorDraftingProcess.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorDraftingProcess.java @@ -296,7 +296,7 @@ else if (names.contains(s) && !FOptionPane.showConfirmDialog( //open draft pool in Draft Deck Editor right away Singletons.getControl().setCurrentScreen(FScreen.DECK_EDITOR_DRAFT); - CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(new CEditorLimited(FModel.getDecks().getDraft(), FScreen.DECK_EDITOR_DRAFT, getCDetailPicture())); + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(new CEditorLimited<>(FModel.getDecks().getDraft(), DeckGroup::new, FScreen.DECK_EDITOR_DRAFT, getCDetailPicture())); CDeckEditorUI.SINGLETON_INSTANCE.getCurrentEditorController().getDeckController().load(null, s); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLimited.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLimited.java index 45855fe6387..fb237883d16 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLimited.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLimited.java @@ -27,7 +27,7 @@ import forge.card.CardEdition; import forge.deck.CardPool; import forge.deck.Deck; -import forge.deck.DeckGroup; +import forge.deck.DeckBase; import forge.deck.DeckSection; import forge.game.GameType; import forge.gui.UiCommand; @@ -60,9 +60,9 @@ * @author Forge * @version $Id: DeckEditorCommon.java 12850 2011-12-26 14:55:09Z slapshot5 $ */ -public final class CEditorLimited extends CDeckEditor { +public final class CEditorLimited extends CDeckEditor { - private final DeckController controller; + private final DeckController controller; private DragCell constructedDecksParent = null; private DragCell commanderDecksParent = null; private DragCell oathbreakerDecksParent = null; @@ -79,7 +79,7 @@ public final class CEditorLimited extends CDeckEditor { * @param deckMap0   {@link forge.deck.DeckGroup}<{@link forge.util.storage.IStorage}> */ @SuppressWarnings("serial") - public CEditorLimited(final IStorage deckMap0, final FScreen screen0, final CDetailPicture cDetailPicture0) { + public CEditorLimited(final IStorage deckMap0, final Supplier newCreator, final FScreen screen0, final CDetailPicture cDetailPicture0) { super(screen0, cDetailPicture0, GameType.Sealed); final CardManager catalogManager = new CardManager(cDetailPicture0, false, false, FScreen.DECK_EDITOR_DRAFT.equals(screen0)); @@ -93,7 +93,6 @@ public CEditorLimited(final IStorage deckMap0, final FScreen screen0, this.setCatalogManager(catalogManager); this.setDeckManager(deckManager); - final Supplier newCreator = DeckGroup::new; this.controller = new DeckController<>(deckMap0, this, newCreator); getBtnAddBasicLands().setCommand((UiCommand) () -> CEditorLimited.addBasicLands(CEditorLimited.this)); @@ -181,11 +180,11 @@ public Boolean isSectionImportable(DeckSection section) { * @see forge.gui.deckeditor.ACEditorBase#getController() */ @Override - public DeckController getDeckController() { + public DeckController getDeckController() { return this.controller; } - public static void addBasicLands(ACEditorBase editor) { + public static void addBasicLands(ACEditorBase editor) { Deck deck = editor.getHumanDeck(); if (deck == null) { return; } diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLog.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLog.java index b4a68d8db91..e6c242896d7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLog.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorLog.java @@ -1,5 +1,7 @@ package forge.screens.deckeditor.controllers; +import java.awt.Color; + import forge.gui.FThreads; import forge.gui.framework.ICDoc; import forge.screens.deckeditor.views.VEditorLog; @@ -32,6 +34,10 @@ public final void addLogEntry(final String entry) { view.addLogEntry(entry); } + public final void addLogEntry(final String message, final Color foreground) { + view.addLogEntry(message, foreground); + } + @Override public void register() { } diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorNetworkDraft.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorNetworkDraft.java new file mode 100644 index 00000000000..31d8479b14d --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorNetworkDraft.java @@ -0,0 +1,292 @@ +package forge.screens.deckeditor.controllers; + +import forge.Singletons; +import forge.deck.Deck; +import forge.game.GameType; +import forge.gamemodes.net.event.DraftPickEvent; +import forge.gui.FDraftOverlay; +import forge.gui.framework.DragCell; +import forge.gui.framework.FScreen; +import forge.item.PaperCard; +import forge.itemmanager.CardManager; +import forge.itemmanager.ItemManagerConfig; +import forge.model.FModel; +import forge.screens.deckeditor.CDeckEditorUI; +import forge.screens.deckeditor.views.VAllDecks; +import forge.screens.deckeditor.views.VBrawlDecks; +import forge.screens.deckeditor.views.VCardCatalog; +import forge.screens.deckeditor.views.VCommanderDecks; +import forge.screens.deckeditor.views.VCurrentDeck; +import forge.screens.deckeditor.views.VDeckgen; +import forge.screens.deckeditor.views.VEditorLog; +import forge.screens.deckeditor.views.VOathbreakerDecks; +import forge.screens.deckeditor.views.VTinyLeadersDecks; +import forge.screens.match.controllers.CDetailPicture; +import forge.toolbox.FOptionPane; +import forge.util.ItemPool; +import forge.util.Localizer; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map.Entry; +import java.util.function.Consumer; + +/** + * Network draft editor controller. Works with a push model: packs arrive + * via {@link forge.gamemodes.net.event.DraftPackArrivedEvent} and picks + * are sent back via a {@link Consumer} callback. + * + *

(C at beginning of class name denotes a control class.) + */ +public class CEditorNetworkDraft extends ACEditorBase { + + private final int seatIndex; + private final Consumer pickSender; + private final Runnable onLeave; + private final Localizer localizer = Localizer.getInstance(); + + private int currentPackNumber; + private int currentPickNumber; + private boolean draftComplete; + + private record PendingSelfPick(String cardName, int packNumber, int pickInPack, boolean auto) { } + private PendingSelfPick pendingSelfPick; + + private String ccAddLabel; + private DragCell constructedDecksParent; + private DragCell commanderDecksParent; + private DragCell oathbreakerDecksParent; + private DragCell brawlDecksParent; + private DragCell tinyLeadersDecksParent; + private DragCell deckGenParent; + + /** + * @param seatIndex this player's seat in the draft pod + * @param pickSender callback to send picks; for the host this calls + * ServerGameLobby.handleDraftPick directly, for + * clients it sends via FGameClient + * @param onLeave callback fired when the user confirms "Leave" on the + * mid-draft exit prompt — lets the lobby drop its + * reference and dismiss the overlay + * @param cDetailPicture0 the shared detail picture controller + */ + public CEditorNetworkDraft(int seatIndex, + Consumer pickSender, Runnable onLeave, + CDetailPicture cDetailPicture0) { + super(FScreen.DRAFTING_PROCESS, cDetailPicture0, GameType.Draft); + + this.seatIndex = seatIndex; + this.pickSender = pickSender; + this.onLeave = onLeave; + + final CardManager catalogManager = new CardManager(cDetailPicture0, false, false, true); + final CardManager deckManager = new CardManager(cDetailPicture0, false, false, true); + + // Hide filters so more of the pack is visible + catalogManager.setHideViewOptions(1, true); + + deckManager.setCaption(localizer.getMessage("lblDraftPicks")); + + catalogManager.setAlwaysNonUnique(true); + deckManager.setAlwaysNonUnique(true); + + this.setCatalogManager(catalogManager); + this.setDeckManager(deckManager); + } + + /** + * Display a new pack for the player to pick from. + * + * @param pack the cards in the pack + * @param packNumber 1-based pack number + * @param pickNumber 0-based pick number within the pack round + */ + public void showPack(List pack, int packNumber, int pickNumber) { + this.currentPackNumber = packNumber; + this.currentPickNumber = pickNumber; + + ItemPool pool = new ItemPool<>(PaperCard.class); + for (PaperCard card : pack) { + pool.add(card, 1); + } + + this.getCatalogManager().setCaption(localizer.getMessage("lblPackNCards", String.valueOf(packNumber))); + this.getCatalogManager().setPool(pool); + this.getCatalogManager().refresh(); + } + + @Override + protected void onAddItems(Iterable> items, boolean toAlternate) { + if (toAlternate || draftComplete) { + return; + } + + // Only one card per invocation — draft flow picks single cards, not groups + Iterator> it = items.iterator(); + if (!it.hasNext()) return; + PaperCard card = it.next().getKey(); + + this.getDeckManager().addItem(card, 1); + pickSender.accept(new DraftPickEvent(seatIndex, card)); + + // Deferred log: flushed on the server's SeatPicked echo so queue-depth data is authoritative + pendingSelfPick = new PendingSelfPick(card.getName(), + currentPackNumber, currentPickNumber + 1, false); + + this.getCatalogManager().setPool(Collections.emptyList()); + FDraftOverlay.SINGLETON_INSTANCE.onPickSubmitted(); + } + + public void addAutoPickedCard(PaperCard card, int packNumber, int pickInPack) { + this.getDeckManager().addItem(card, 1); + pendingSelfPick = new PendingSelfPick(card.getName(), packNumber, pickInPack, true); + FDraftOverlay.SINGLETON_INSTANCE.onPickSubmitted(); + } + + public void flushSelfPickLog(int queueDepth) { + PendingSelfPick p = pendingSelfPick; + if (p == null) return; + pendingSelfPick = null; + NetworkDraftLog.logMyPick(p.cardName, p.packNumber, p.pickInPack, queueDepth, p.auto); + } + + @Override + protected void onRemoveItems(Iterable> items, boolean toAlternate) { + // Cannot remove cards during draft + } + + @Override + protected void buildAddContextMenu(EditorContextMenuBuilder cmb) { + cmb.addMoveItems(localizer.getMessage("lblDraft"), null); + } + + @Override + protected void buildRemoveContextMenu(EditorContextMenuBuilder cmb) { + // No valid remove options during draft + } + + /** + * Called when the draft is complete and the pool arrives from the server. + * + * @param pool the drafted pool (Sideboard section holds picks + event metadata tags) + */ + public void completeDraft(Deck pool) { + draftComplete = true; + FModel.getDecks().getNetworkEventDecks().add(pool); + + int totalCards = 0; + if (getDeckManager().getPool() != null) { + totalCards = getDeckManager().getPool().countAll(); + } + NetworkDraftLog.logDraftComplete(totalCards); + FDraftOverlay.SINGLETON_INSTANCE.reset(); + FScreen.DRAFTING_PROCESS.close(); + + // Reuse the sealed editor for post-draft pool editing — the UI is identical + FScreen editScreen = FScreen.DECK_EDITOR_SEALED; + CEditorLimited editorCtrl = new CEditorLimited<>( + FModel.getDecks().getNetworkEventDecks(), Deck::new, editScreen, getCDetailPicture()); + Singletons.getControl().setCurrentScreen(editScreen); + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(editorCtrl); + editorCtrl.getDeckController().load(null, pool.getName()); + + FOptionPane.showMessageDialog(localizer.getMessage("lblDraftCompletePoolSaved", pool.getName())); + } + + @Override + protected CardLimit getCardLimit() { + return CardLimit.None; + } + + @Override + public DeckController getDeckController() { + return null; + } + + @Override + public void resetTables() { + } + + @Override + public void update() { + this.getCatalogManager().setup(ItemManagerConfig.DRAFT_PACK); + this.getDeckManager().setup(ItemManagerConfig.DRAFT_POOL); + + if (VEditorLog.SINGLETON_INSTANCE.getParentCell() == null) { + VCardCatalog.SINGLETON_INSTANCE.getParentCell().addDoc(VEditorLog.SINGLETON_INSTANCE); + } + + ccAddLabel = this.getBtnAdd().getText(); + + // Start with an empty catalog — packs arrive via events + if (this.getDeckManager().getPool() == null) { + this.getDeckManager().setPool(Collections.emptyList()); + } + + this.getBtnAdd().setVisible(false); + this.getBtnAdd4().setVisible(false); + this.getBtnRemove().setVisible(false); + this.getBtnRemove4().setVisible(false); + + this.getCbxSection().setVisible(false); + + VCurrentDeck.SINGLETON_INSTANCE.getPnlHeader().setVisible(false); + + deckGenParent = removeTab(VDeckgen.SINGLETON_INSTANCE); + constructedDecksParent = removeTab(VAllDecks.SINGLETON_INSTANCE); + commanderDecksParent = removeTab(VCommanderDecks.SINGLETON_INSTANCE); + oathbreakerDecksParent = removeTab(VOathbreakerDecks.SINGLETON_INSTANCE); + brawlDecksParent = removeTab(VBrawlDecks.SINGLETON_INSTANCE); + tinyLeadersDecksParent = removeTab(VTinyLeadersDecks.SINGLETON_INSTANCE); + + // One pick per click — draft flow doesn't support group-picking + getCatalogManager().setAllowMultipleSelections(false); + } + + @Override + public boolean canSwitchAway(boolean isClosing) { + if (isClosing && !draftComplete) { + String userPrompt = localizer.getMessage("lblEndDraftConfirm"); + boolean leaving = FOptionPane.showConfirmDialog(userPrompt, + localizer.getMessage("lblLeaveDraft"), + localizer.getMessage("lblLeave"), + localizer.getMessage("lblCancel"), false); + if (leaving && onLeave != null) onLeave.run(); + return leaving; + } + return true; + } + + @Override + public void resetUIChanges() { + this.getBtnAdd().setText(ccAddLabel); + this.getBtnAdd4().setVisible(true); + this.getBtnRemove().setVisible(true); + this.getBtnRemove4().setVisible(true); + + VCurrentDeck.SINGLETON_INSTANCE.getPnlHeader().setVisible(true); + VEditorLog.SINGLETON_INSTANCE.getParentCell().setVisible(true); + + if (deckGenParent != null) { + deckGenParent.addDoc(VDeckgen.SINGLETON_INSTANCE); + } + if (constructedDecksParent != null) { + constructedDecksParent.addDoc(VAllDecks.SINGLETON_INSTANCE); + } + if (commanderDecksParent != null) { + commanderDecksParent.addDoc(VCommanderDecks.SINGLETON_INSTANCE); + } + if (oathbreakerDecksParent != null) { + oathbreakerDecksParent.addDoc(VOathbreakerDecks.SINGLETON_INSTANCE); + } + if (brawlDecksParent != null) { + brawlDecksParent.addDoc(VBrawlDecks.SINGLETON_INSTANCE); + } + if (tinyLeadersDecksParent != null) { + tinyLeadersDecksParent.addDoc(VTinyLeadersDecks.SINGLETON_INSTANCE); + } + + getCatalogManager().setAllowMultipleSelections(true); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorWinstonProcess.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorWinstonProcess.java index b81f0766a9a..6e71229ae6f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorWinstonProcess.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CEditorWinstonProcess.java @@ -242,7 +242,7 @@ private void saveDraft() { //open draft pool in Draft Deck Editor right away Singletons.getControl().setCurrentScreen(FScreen.DECK_EDITOR_DRAFT); - CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(new CEditorLimited(FModel.getDecks().getWinston(), FScreen.DECK_EDITOR_DRAFT, getCDetailPicture())); + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(new CEditorLimited<>(FModel.getDecks().getWinston(), DeckGroup::new, FScreen.DECK_EDITOR_DRAFT, getCDetailPicture())); CDeckEditorUI.SINGLETON_INSTANCE.getCurrentEditorController().getDeckController().load(null, s); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/DeckController.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/DeckController.java index ba849807ebe..684d22becc5 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/DeckController.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/DeckController.java @@ -139,7 +139,7 @@ private Deck pickFromCatalog(Deck deck, CardPool catalog) { // Getting Latest among the earliest editions in catalog! CardEdition referenceEdition = StaticData.instance().getEditions().getTheLatestOfAllTheOriginalEditionsOfCardsIn(catalog); Date referenceReleaseDate = referenceEdition.getDate(); - Deck result = new Deck(); + Deck result = new Deck(deck.getName()); for (DeckSection section: DeckSection.values()) { if (view.isSectionImportable(section)) { CardPool cards = pickSectionFromCatalog(catalog, deck.getOrCreate(section), referenceReleaseDate); diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/NetworkDraftLog.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/NetworkDraftLog.java new file mode 100644 index 00000000000..5678fd362bd --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/NetworkDraftLog.java @@ -0,0 +1,87 @@ +package forge.screens.deckeditor.controllers; + +import forge.gamemodes.net.EventParticipant; +import forge.util.Localizer; + +import java.awt.Color; +import java.util.List; + +/** + * Utility for logging network draft events to the existing Editor Log tab + * ({@link forge.screens.deckeditor.controllers.CEditorLog} / {@link forge.screens.deckeditor.views.VEditorLog}). + * All entries are driven by protocol events — no new network messages needed. + */ +public final class NetworkDraftLog { + private static final Color COLOR_BANNER = new Color(100, 150, 200); // muted blue + private static final Color COLOR_SEPARATOR = new Color(130, 130, 130); // gray + private static final Color COLOR_MY_PICK = new Color(50, 200, 50); // green + private static final Color COLOR_OTHER_PICK = new Color(180, 180, 180); // light gray + + private static final String BANNER = "======================================"; + + private static final Localizer localizer = Localizer.getInstance(); + + private NetworkDraftLog() { } // utility class + + /** Log the draft start banner with pod information. */ + public static void logDraftStart(List participants, int totalPacks, + String productName, int mySeatIndex) { + log(BANNER, COLOR_BANNER); + log(" " + localizer.getMessage("lblDraftLogDraftStarted", String.valueOf(participants.size())), COLOR_BANNER); + log(" " + localizer.getMessage("lblDraftLogPacksOf", String.valueOf(totalPacks), productName), COLOR_BANNER); + + StringBuilder humans = new StringBuilder(" " + localizer.getMessage("lblDraftLogPlayersYou")); + StringBuilder ais = new StringBuilder(" " + localizer.getMessage("lblDraftLogAiSeats")); + boolean hasAI = false; + for (EventParticipant p : participants) { + if (p.isHuman() && p.getSeatIndex() != mySeatIndex) { + humans.append(", ").append(p.getName()); + } else if (p.isAI()) { + ais.append(" ").append(p.getName()).append(","); + hasAI = true; + } + } + log(humans.toString(), COLOR_BANNER); + if (hasAI) { + // Trim trailing comma + log(ais.substring(0, ais.length() - 1), COLOR_BANNER); + } + log(BANNER, COLOR_BANNER); + } + + /** Log a pack round header. */ + public static void logPackHeader(int packNumber, boolean passingRight) { + String direction = passingRight ? localizer.getMessage("lblDraftLogPassingRight") + : localizer.getMessage("lblDraftLogPassingLeft"); + log(localizer.getMessage("lblDraftLogPackHeader", String.valueOf(packNumber), direction), COLOR_SEPARATOR); + } + + public static void logOtherPick(String playerName, int queueDepth) { + String base = localizer.getMessage("lblDraftLogOtherPick", playerName); + log(base + waitingSuffix(queueDepth), COLOR_OTHER_PICK); + } + + public static void logMyPick(String cardName, int packNumber, int pickInPack, int queueDepth, boolean auto) { + String displayName = auto ? cardName + " (auto)" : cardName; + String base = localizer.getMessage("lblDraftLogMyPick", displayName, + String.valueOf(packNumber), String.valueOf(pickInPack)); + log(base + waitingSuffix(queueDepth), COLOR_MY_PICK); + } + + private static String waitingSuffix(int queueDepth) { + if (queueDepth <= 0) return ""; + return " " + localizer.getMessage("lblDraftLogWaiting", String.valueOf(queueDepth)); + } + + /** Log draft completion. */ + public static void logDraftComplete(int totalCards) { + log(BANNER, COLOR_BANNER); + log(" " + localizer.getMessage("lblDraftLogDraftComplete", String.valueOf(totalCards)), COLOR_BANNER); + log(" " + localizer.getMessage("lblDraftLogBuildingDeck"), COLOR_BANNER); + log(BANNER, COLOR_BANNER); + } + + private static void log(String message, Color color) { + CEditorLog.SINGLETON_INSTANCE.addLogEntry(message, color); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/views/VEditorLog.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/views/VEditorLog.java index e101a4ede38..e6572ea2d87 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/views/VEditorLog.java +++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/views/VEditorLog.java @@ -12,6 +12,7 @@ import net.miginfocom.swing.MigLayout; import javax.swing.*; +import java.awt.Color; import java.util.List; /** @@ -34,7 +35,9 @@ public enum VEditorLog implements IVDoc { private final FScrollPane scroller = new FScrollPane(pnlContent, false); - private final List editorLogEntries = Lists.newArrayList(); + private record LogEntry(String text, Color color) { } + private final List entries = Lists.newArrayList(); + private boolean draftLogVisible = true; VEditorLog() { pnlContent.setOpaque(false); @@ -86,9 +89,8 @@ public void populate() { } public void resetNewDraft() { - // Should we store the draft? gameLog.reset(); - editorLogEntries.clear(); + entries.clear(); } public void updateConsole() { @@ -96,7 +98,22 @@ public void updateConsole() { } public void addLogEntry(String entry) { - gameLog.addLogEntry(entry); - this.editorLogEntries.add(entry); + addLogEntry(entry, null); + } + + public void addLogEntry(String entry, Color foreground) { + entries.add(new LogEntry(entry, foreground)); + if (draftLogVisible) { + gameLog.addLogEntry(entry, foreground); + } + } + + public void setDraftLogVisible(boolean visible) { + if (draftLogVisible == visible) return; + draftLogVisible = visible; + gameLog.reset(); + if (visible) { + for (LogEntry e : entries) gameLog.addLogEntry(e.text, e.color); + } } } diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/CLobby.java b/forge-gui-desktop/src/main/java/forge/screens/home/CLobby.java index d255c9a4910..0a32da4140b 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/CLobby.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/CLobby.java @@ -1,22 +1,144 @@ package forge.screens.home; +import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Vector; +import java.util.function.Consumer; +import javax.swing.JPanel; import javax.swing.SwingUtilities; import com.google.common.collect.Iterables; + +import forge.Singletons; +import forge.deck.Deck; import forge.deck.DeckProxy; +import forge.gamemodes.limited.BoosterDraft; +import forge.gamemodes.limited.LimitedPoolType; +import forge.gamemodes.match.GameLobby; +import forge.gamemodes.match.LobbySlot; +import forge.gamemodes.net.EventFormat; +import forge.gamemodes.net.EventParticipant; +import forge.gamemodes.net.NetworkEvent; +import forge.gamemodes.net.NetworkEventView; +import forge.gamemodes.net.client.FGameClient; +import forge.gamemodes.net.event.DraftPickEvent; +import forge.gamemodes.net.server.ServerGameLobby; +import forge.gui.FDraftOverlay; +import forge.gui.GuiChoose; +import forge.gui.framework.FScreen; +import forge.gui.util.SOptionPane; +import forge.item.PaperCard; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; +import forge.screens.deckeditor.CDeckEditorUI; +import forge.screens.deckeditor.controllers.CEditorLimited; +import forge.screens.deckeditor.controllers.CEditorNetworkDraft; +import forge.screens.deckeditor.controllers.NetworkDraftLog; +import forge.screens.deckeditor.views.VEditorLog; +import forge.screens.home.online.VSubmenuOnlineLobby; +import forge.toolbox.FLabel; import forge.toolbox.FList; +import forge.toolbox.FOptionPane; +import forge.toolbox.FTextField; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; public class CLobby { + public enum LobbyMode { CONSTRUCTED, LIMITED } + + /** Desktop event-panel render contract: shared text content + desktop widget visibility. */ + public record EventPanelContents( + String formatText, + String productText, + String timerText, + String dateText, + String statusText, + boolean showDismissX, + boolean showConformance, + boolean conformanceEnabled) { } + private final VLobby view; + private LobbyMode currentMode = LobbyMode.CONSTRUCTED; + private boolean suppressModeListener; + + // Event state (network lobby only) + private EventFormat configuredFormat; + private String activeEventId; + private boolean activeConformance = true; + private List eventIdsByDropdownIndex = new ArrayList<>(); + private NetworkEventView lastEventView; + private int mySeatIndex; + private int lastPackNumber; + private CEditorNetworkDraft networkDraftEditor; + public CLobby(final VLobby view) { this.view = view; + view.setController(this); + } + + public boolean isLimitedMode() { return currentMode == LobbyMode.LIMITED; } + public EventFormat getConfiguredFormat() { return configuredFormat; } + public String getActiveEventId() { return activeEventId; } + public boolean isActiveConformance() { return activeConformance; } + + /** Client: sync the combo selection to the host's mode without re-firing onModeChanged. */ + void syncModeFromHost() { + if (!view.getLobby().isAllowNetworking() || view.getLobby().hasControl()) return; + if (view.getLobby().getData() == null) return; + boolean hostIsLimited = view.getLobby().getData().isLimitedMode(); + int desiredIndex = hostIsLimited ? 1 : 0; + if (view.getCurrentModeIndex() != desiredIndex) { + suppressModeListener = true; + try { + view.setCurrentModeIndex(desiredIndex); + currentMode = hostIsLimited ? LobbyMode.LIMITED : LobbyMode.CONSTRUCTED; + view.setVariantsVisible(!hostIsLimited); + } finally { + suppressModeListener = false; + } + } + } + + void onModeChanged() { + if (suppressModeListener) return; + + // Client: mode is host-controlled. If a user click diverges from the synced value, + // revert via setCurrentModeIndex (which re-fires this listener). + if (view.getLobby().isAllowNetworking() && !view.getLobby().hasControl()) { + boolean hostIsLimited = view.getLobby().getData() != null && view.getLobby().getData().isLimitedMode(); + int desiredIndex = hostIsLimited ? 1 : 0; + if (view.getCurrentModeIndex() != desiredIndex) { + view.setCurrentModeIndex(desiredIndex); + return; + } + } + + final String selected = view.getCurrentModeSelection(); + if (Localizer.getInstance().getMessage("lblNetworkModeLimited").equals(selected)) { + currentMode = LobbyMode.LIMITED; + } else { + currentMode = LobbyMode.CONSTRUCTED; + } + final boolean isLimited = (currentMode == LobbyMode.LIMITED); + + // Clear event when switching away from Limited, and broadcast the new mode. + if (view.getLobby().hasControl() && view.getLobby() instanceof ServerGameLobby serverLobby) { + if (!isLimited) { + configuredFormat = null; + serverLobby.clearCurrentEvent(); + } + serverLobby.setLimitedMode(isLimited); + } + view.updateEventPanelState(); + view.setVariantsVisible(!isLimited); + view.updateRightPanelForMode(); + view.updateActionButtons(); + view.refreshConstructedFrame(); } private void addDecks(final Iterable commanderDecks, FList deckList, String... initialItems) { @@ -57,6 +179,358 @@ public void update() { view.getGamesInMatchBinder().load(); } + /** React to a lobby-data change: detect event-state transitions and refresh the panel. */ + public void onLobbyDataChanged() { + GameLobby.GameLobbyData data = view.getLobby().getData(); + if (data == null) return; + + boolean eventPanelNeedsUpdate = false; + NetworkEventView newView = data.getEventView(); + if (newView != lastEventView) { + lastEventView = newView; + eventPanelNeedsUpdate = true; + } + String newEventId = data.getActiveEventId(); + boolean newConformance = data.isActiveConformance(); + if (!java.util.Objects.equals(newEventId, activeEventId) || newConformance != activeConformance) { + activeEventId = newEventId; + activeConformance = newConformance; + if (!view.getLobby().hasControl()) { + view.setConformanceSelected(newConformance); + view.updateDeckListFilter(); + } + eventPanelNeedsUpdate = true; + } + if (eventPanelNeedsUpdate) { + refreshEventPanel(); + } + } + + /** Compute the event panel contents and push to the view. */ + public void refreshEventPanel() { + if (!view.getLobby().isAllowNetworking()) return; + view.setEventPanelContents(buildEventPanelContents()); + } + + private EventPanelContents buildEventPanelContents() { + final boolean isHost = view.getLobby().hasControl(); + final NetworkEvent currentEvent = (view.getLobby() instanceof ServerGameLobby sgl) ? sgl.getCurrentEvent() : null; + final boolean inState2 = activeEventId != null; + final boolean inState1 = !inState2 && (isHost ? currentEvent != null : lastEventView != null); + + NetworkEvent.EventPanelText text = NetworkEvent.computeEventPanelText( + isHost, activeEventId, currentEvent, lastEventView); + + return new EventPanelContents( + text.formatText(), text.productText(), text.timerText(), + text.dateText(), text.statusText(), + isHost && (inState1 || inState2), + inState2, + isHost && !inState1); + } + + void onDismissEvent() { + if (view.getLobby() instanceof ServerGameLobby serverLobby) { + configuredFormat = null; + serverLobby.clearCurrentEvent(); + } + activeEventId = null; + broadcastEventSelection(); + view.updateEventPanelState(); + view.updateActionButtons(); + view.updateDeckListFilter(); + } + + void onConformanceChanged() { + activeConformance = view.getConformanceSelected(); + view.updateDeckListFilter(); + broadcastEventSelection(); + } + + void broadcastEventSelection() { + if (view.getLobby().hasControl() && view.getLobby() instanceof ServerGameLobby serverLobby) { + serverLobby.selectEventForMatch(activeEventId, activeConformance); + } + } + + void scanAvailableEvents() { + LinkedHashSet eventIds = new LinkedHashSet<>(); + for (Deck d : FModel.getDecks().getNetworkEventDecks()) { + String eventId = DeckProxy.getEventTag(d, "eventId"); + if (eventId != null) eventIds.add(eventId); + } + eventIdsByDropdownIndex = new ArrayList<>(eventIds); + } + + void openEventConfigDialog() { + if (!(view.getLobby() instanceof ServerGameLobby serverLobby)) return; + Localizer localizer = Localizer.getInstance(); + + // Step 0: If past events exist, offer a choice between creating new and loading one + if (!eventIdsByDropdownIndex.isEmpty()) { + String[] setupOptions = { + localizer.getMessage("lblNetworkSetUpEventCreate"), + localizer.getMessage("lblNetworkSetUpEventLoadPast") + }; + String setupChoice = GuiChoose.oneOrNone( + localizer.getMessage("lblNetworkSetUpEventPrompt"), setupOptions); + if (setupChoice == null) return; + if (setupChoice.equals(setupOptions[1])) { + openLoadPastEventDialog(); + return; + } + } + + // Step 1: Choose event type (Draft / Sealed) + String[] formatNames = { localizer.getMessage("lblNetworkModeDraft"), localizer.getMessage("lblNetworkModeSealed") }; + String chosenFormatName = GuiChoose.oneOrNone( + localizer.getMessage("lblNetworkChooseEventType"), formatNames); + if (chosenFormatName == null) return; + EventFormat chosenFormat = formatNames[0].equals(chosenFormatName) + ? EventFormat.BOOSTER_DRAFT : EventFormat.SEALED; + + serverLobby.createEvent(chosenFormat); + configuredFormat = chosenFormat; + + // Step 2: Choose pool type + boolean isDraft = (chosenFormat == EventFormat.BOOSTER_DRAFT); + LimitedPoolType[] poolTypes = LimitedPoolType.values(isDraft); + LimitedPoolType chosen = GuiChoose.oneOrNone( + localizer.getMessage("lblNetworkChooseDraftFormat"), poolTypes); + if (chosen == null) return; + + // Step 3: For draft, build the BoosterDraft now so block/set/cube/theme sub-dialogs + // pop before the timer prompt — matches the offline CSubmenuDraft flow. + BoosterDraft draft = null; + if (isDraft) { + draft = BoosterDraft.createDraftForNetwork(chosen); + if (draft == null) return; + } + + NetworkEvent event = serverLobby.getCurrentEvent(); + if (event == null) return; + + // Step 4: Pick timer + disconnect grace period (draft only, combined prompt) + int timerSeconds = event.getPickTimerSeconds(); + int graceSeconds = event.getDisconnectGraceSeconds(); + if (isDraft) { + FTextField pickField = new FTextField.Builder().text(String.valueOf(timerSeconds)).build(); + FTextField graceField = new FTextField.Builder().text(String.valueOf(graceSeconds)).build(); + FLabel pickLbl = new FLabel.Builder().fontSize(12).text(localizer.getMessage("lblNetworkPickTimerPrompt")).build(); + FLabel graceLbl1 = new FLabel.Builder().fontSize(12).text(localizer.getMessage("lblNetworkGraceTimerPromptLine1")).build(); + FLabel graceLbl2 = new FLabel.Builder().fontSize(12).text(localizer.getMessage("lblNetworkGraceTimerPromptLine2")).build(); + + JPanel panel = new JPanel(new MigLayout("insets 4, gap 2 4, wrap 1")); + panel.setOpaque(false); + panel.add(pickLbl); + panel.add(pickField, "w 80!"); + panel.add(graceLbl1, "gaptop 10"); + panel.add(graceLbl2); + panel.add(graceField, "w 80!"); + + int result = FOptionPane.showOptionDialog( + null, + localizer.getMessage("lblNetworkDraftTimersTitle"), + null, + panel, + Arrays.asList( + localizer.getMessage("lblOK"), + localizer.getMessage("lblCancel"))); + if (result == 0) { + try { + int parsed = Integer.parseInt(pickField.getText().trim()); + if (parsed >= 0) timerSeconds = parsed; + } catch (NumberFormatException ignored) { } + try { + int parsed = Integer.parseInt(graceField.getText().trim()); + if (parsed >= 0) graceSeconds = parsed; + } catch (NumberFormatException ignored) { } + } + } + + if (!serverLobby.configureEvent(chosen, draft, timerSeconds, graceSeconds)) { + return; + } + view.updateEventPanelState(); + view.updateActionButtons(); + } + + void openLoadPastEventDialog() { + if (eventIdsByDropdownIndex.isEmpty()) return; + List choices = new ArrayList<>(eventIdsByDropdownIndex.size()); + for (String id : eventIdsByDropdownIndex) { + choices.add(new NetworkEvent.EventChoice(id, NetworkEvent.getEventDisplayLabel(id))); + } + NetworkEvent.EventChoice chosen = GuiChoose.oneOrNone( + Localizer.getInstance().getMessage("lblNetworkLoadPastEventPrompt"), choices); + if (chosen == null) return; + activeEventId = chosen.id(); + view.updateEventPanelState(); + view.updateActionButtons(); + view.updateDeckListFilter(); + broadcastEventSelection(); + } + + void startEvent() { + if (!(view.getLobby() instanceof ServerGameLobby serverLobby)) return; + Localizer localizer = Localizer.getInstance(); + NetworkEvent event = serverLobby.getCurrentEvent(); + if (event == null) { + FOptionPane.showErrorDialog(localizer.getMessage("lblNetworkNoEventConfigured")); + return; + } + + LobbySlot unready = view.getLobby().findFirstUnreadySlot(); + if (unready != null) { + SOptionPane.showMessageDialog(localizer.getMessage("lblPlayerIsNotReady", unready.getName())); + return; + } + + if (event.getFormat() == EventFormat.SEALED) { + serverLobby.startSealedEvent(); + } else if (event.getFormat() == EventFormat.BOOSTER_DRAFT) { + ServerGameLobby.DraftStartResult result = serverLobby.startDraftEvent(); + if (result == null) { + FOptionPane.showErrorDialog(localizer.getMessage("lblNetworkFailedDraft")); + return; + } + mySeatIndex = result.hostSeatIndex(); + FDraftOverlay.SINGLETON_INSTANCE.initDraft( + mySeatIndex, result.names(), result.aiFlags(), result.totalPacks()); + NetworkDraftLog.logDraftStart( + event.getParticipants(), result.totalPacks(), + event.getProductDescription(), mySeatIndex); + lastPackNumber = 0; + } + } + + void onDraftPackArrived(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { + SwingUtilities.invokeLater(() -> { + if (networkDraftEditor == null) { + initDraftEditor(seatIndex); + } + + FDraftOverlay.SINGLETON_INSTANCE.onPackArrived(packNumber, pickNumber, pack.size(), timerDurationSeconds); + + if (packNumber != lastPackNumber) { + lastPackNumber = packNumber; + boolean passingRight = (packNumber % 2 == 1); + NetworkDraftLog.logPackHeader(packNumber, passingRight); + } + + networkDraftEditor.showPack(pack, packNumber, pickNumber); + }); + } + + private void initDraftEditor(int seatIndex) { + mySeatIndex = seatIndex; + + // Prefer the latest state's eventView over the cached copy — the first lobby + // broadcast during configureEvent() carries an empty participants list. + if (view.getLobby().getData() != null && view.getLobby().getData().getEventView() != null) { + lastEventView = view.getLobby().getData().getEventView(); + } + if (lastEventView != null) { + List participants = lastEventView.getParticipants(); + int totalPacks = lastEventView.getNumRounds(); + String[] names = new String[participants.size()]; + boolean[] aiFlags = new boolean[participants.size()]; + for (EventParticipant p : participants) { + int seat = p.getSeatIndex(); + if (seat >= 0 && seat < names.length) { + names[seat] = p.getName(); + aiFlags[seat] = p.isAI(); + } + } + FDraftOverlay.SINGLETON_INSTANCE.initDraft(mySeatIndex, names, aiFlags, totalPacks); + NetworkDraftLog.logDraftStart( + participants, totalPacks, + lastEventView.getProductDescription(), mySeatIndex); + } + + Consumer pickSender; + if (view.getLobby() instanceof ServerGameLobby serverLobby) { + pickSender = ev -> serverLobby.handleDraftPick(ev, -1); + } else { + FGameClient gameClient = VSubmenuOnlineLobby.SINGLETON_INSTANCE.getClient(); + if (gameClient == null) return; + pickSender = gameClient::send; + } + + networkDraftEditor = new CEditorNetworkDraft( + mySeatIndex, pickSender, this::cancelActiveDraft, + CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); + VEditorLog.SINGLETON_INSTANCE.resetNewDraft(); + + Singletons.getControl().setCurrentScreen(FScreen.DRAFTING_PROCESS); + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(networkDraftEditor); + } + + private String resolveParticipantName(int seatIndex) { + List viewParticipants = lastEventView != null ? lastEventView.getParticipants() : null; + List currentParticipants = (view.getLobby() instanceof ServerGameLobby sgl + && sgl.getCurrentEvent() != null) ? sgl.getCurrentEvent().getParticipants() : null; + return EventParticipant.resolveName(seatIndex, viewParticipants, currentParticipants); + } + + void onDraftSeatPicked(int seatIndex, int[] seatQueueDepths) { + SwingUtilities.invokeLater(() -> { + FDraftOverlay.SINGLETON_INSTANCE.onSeatPicked(seatQueueDepths); + + int depth = (seatIndex >= 0 && seatIndex < seatQueueDepths.length) ? seatQueueDepths[seatIndex] : 0; + if (seatIndex == mySeatIndex) { + if (networkDraftEditor != null) { + networkDraftEditor.flushSelfPickLog(depth); + } + } else { + NetworkDraftLog.logOtherPick(resolveParticipantName(seatIndex), depth); + } + }); + } + + void onDraftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack) { + SwingUtilities.invokeLater(() -> { + if (networkDraftEditor != null) { + networkDraftEditor.addAutoPickedCard(card, packNumber, pickInPack); + } + }); + } + + /** Release draft editor and overlay. Safe to call when no draft is active. */ + public void cancelActiveDraft() { + networkDraftEditor = null; + FDraftOverlay.SINGLETON_INSTANCE.reset(); + } + + void onReceiveEventPool(String eventId, Deck pool) { + SwingUtilities.invokeLater(() -> { + if (networkDraftEditor != null) { + networkDraftEditor.completeDraft(pool); + networkDraftEditor = null; + } else { + FModel.getDecks().getNetworkEventDecks().add(pool); + CEditorLimited editor = new CEditorLimited<>( + FModel.getDecks().getNetworkEventDecks(), Deck::new, + FScreen.DECK_EDITOR_SEALED, CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); + Singletons.getControl().setCurrentScreen(FScreen.DECK_EDITOR_SEALED); + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(editor); + editor.getDeckController().load(null, pool.getName()); + } + lastPackNumber = 0; + + activeEventId = eventId; + activeConformance = true; + if (view.getLobby().hasControl()) { + scanAvailableEvents(); + broadcastEventSelection(); + } + view.updateRightPanelForMode(); + view.updateEventPanelState(); + view.updateActionButtons(); + }); + } + public void initialize() { final ForgePreferences prefs = FModel.getPreferences(); // Checkbox event handling diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/VHomeUI.java b/forge-gui-desktop/src/main/java/forge/screens/home/VHomeUI.java index 5b9cd5aa68f..ca049c21c4f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/VHomeUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/VHomeUI.java @@ -42,6 +42,7 @@ import forge.localinstance.skin.FSkinProp; import forge.model.FModel; import forge.screens.home.gauntlet.*; +import forge.screens.home.online.VSubmenuOnlineDecks; import forge.screens.home.online.VSubmenuOnlineLobby; import forge.screens.home.puzzle.VSubmenuPuzzleCreate; import forge.screens.home.puzzle.VSubmenuPuzzleSolve; @@ -120,6 +121,7 @@ public enum VHomeUI implements IVTopLevelUI { //allSubmenus.add(VSubmenuWinston.SINGLETON_INSTANCE); allSubmenus.add(VSubmenuOnlineLobby.SINGLETON_INSTANCE); + allSubmenus.add(VSubmenuOnlineDecks.SINGLETON_INSTANCE); allSubmenus.add(VSubmenuQuestStart.SINGLETON_INSTANCE); allSubmenus.add(VSubmenuQuestLoadData.SINGLETON_INSTANCE); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java b/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java index 3ec3b3ef7fd..b8caee8c214 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java @@ -1,14 +1,11 @@ package forge.screens.home; +import java.awt.Component; +import java.awt.Container; import java.awt.Font; import java.awt.event.ActionListener; import java.awt.event.ItemEvent; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Vector; +import java.util.*; import javax.swing.*; import javax.swing.event.ListSelectionListener; @@ -18,39 +15,28 @@ import com.google.common.collect.Lists; import forge.ai.AIOption; -import forge.deck.CardPool; -import forge.deck.Deck; -import forge.deck.DeckProxy; -import forge.deck.DeckSection; -import forge.deck.DeckType; -import forge.deck.DeckgenUtil; -import forge.deck.RandomDeckGenerator; +import forge.deck.*; import forge.deckchooser.FDeckChooser; import forge.game.GameType; import forge.game.card.CardView; import forge.gamemodes.match.GameLobby; import forge.gamemodes.match.LobbySlot; import forge.gamemodes.match.LobbySlotType; +import forge.gamemodes.net.*; import forge.gamemodes.net.event.UpdateLobbyPlayerEvent; import forge.gui.CardDetailPanel; import forge.gui.SwingPrefBinders; import forge.gui.interfaces.ILobbyView; import forge.gui.util.SOptionPane; import forge.interfaces.IPlayerChangeListener; +import forge.localinstance.skin.FSkinProp; import forge.item.PaperCard; +import forge.itemmanager.ItemManagerConfig; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; -import forge.toolbox.FCheckBox; -import forge.toolbox.FLabel; -import forge.toolbox.FList; -import forge.toolbox.FOptionPane; -import forge.toolbox.FPanel; -import forge.toolbox.FScrollPane; -import forge.toolbox.FScrollPanel; -import forge.toolbox.FSkin; +import forge.toolbox.*; import forge.toolbox.FSkin.SkinImage; -import forge.toolbox.FTextField; import forge.util.*; import net.miginfocom.swing.MigLayout; @@ -62,11 +48,14 @@ public class VLobby implements ILobbyView { static final int MAX_PLAYERS = 8; + private static final int EVENT_BTN_WIDTH = 200; + private static final int EVENT_BTN_HEIGHT = 50; final Localizer localizer = Localizer.getInstance(); private static final ForgePreferences prefs = FModel.getPreferences(); // General variables private final GameLobby lobby; + private CLobby controller; private IPlayerChangeListener playerChangeListener = null; private final LblHeader lblTitle = new LblHeader(localizer.getMessage("lblHeaderConstructedMode")); private int activePlayersNum = 0; @@ -78,7 +67,7 @@ public class VLobby implements ILobbyView { private final SwingPrefBinders.ComboBox gamesInMatchBinder = new SwingPrefBinders.ComboBox(FPref.UI_MATCHES_PER_GAME, gamesInMatch); private final JPanel gamesInMatchFrame = new JPanel(new MigLayout("insets 0, gap 0, wrap 2")); - private final JPanel constructedFrame = new JPanel(new MigLayout("insets 0, gap 0, wrap 2")); // Main content frame + private final JPanel constructedFrame = new JPanel(new MigLayout("insets 0, gap 0, wrap 2, hidemode 3")); // Main content frame // Variants frame and variables private final FPanel variantsPanel = new FPanel(new MigLayout("insets 10, gapx 10")); @@ -128,12 +117,105 @@ public class VLobby implements ILobbyView { private final Vector humanListData = new Vector<>(); private final Vector aiListData = new Vector<>(); + // Mode selector (network only). Mode state lives in CLobby; this combo is the widget. + private final FComboBoxPanel cboModePanel = new FComboBoxPanel<>(Localizer.getInstance().getMessage("lblNetworkLobbyMode"), + ImmutableList.of(Localizer.getInstance().getMessage("lblNetworkModeConstructed"), + Localizer.getInstance().getMessage("lblNetworkModeLimited"))); + + // Event config panel (top of right panel in Draft/Sealed mode) + private final FPanel eventConfigPanel = new FPanel(new MigLayout("insets 5 10 15 10, gap 2, wrap")); + private final FLabel lblEventFormat = new FLabel.Builder().text("\u2014").fontSize(14).fontStyle(Font.BOLD).fontAlign(javax.swing.SwingConstants.LEFT).build(); + private final FLabel lblEventProduct = new FLabel.Builder().text("\u2014").fontSize(14).fontStyle(Font.BOLD).fontAlign(javax.swing.SwingConstants.LEFT).build(); + private final FLabel lblEventPanelTitle = new FLabel.Builder().text(Localizer.getInstance().getMessage("lblNetworkEventDetailsTitle")).fontSize(15).fontStyle(Font.BOLD).build(); + private final FLabel lblEventStatus = new FLabel.Builder().fontSize(12).fontStyle(Font.ITALIC).build(); + private final FLabel lblEventFormatCaption = new FLabel.Builder().text(Localizer.getInstance().getMessage("lblNetworkFormatCaption")).fontSize(13).build(); + private final FLabel lblEventProductCaption = new FLabel.Builder().text(Localizer.getInstance().getMessage("lblNetworkProductCaption")).fontSize(13).build(); + private final FLabel lblEventPickTimerCaption = new FLabel.Builder().text(Localizer.getInstance().getMessage("lblNetworkPickTimerCaption")).fontSize(13).build(); + private final FLabel lblEventDateCaption = new FLabel.Builder().text(Localizer.getInstance().getMessage("lblNetworkDateCaption")).fontSize(13).build(); + private final FLabel lblEventDate = new FLabel.Builder().text("\u2014").fontSize(14).fontStyle(Font.BOLD).fontAlign(javax.swing.SwingConstants.LEFT).build(); + private final FLabel lblEventPickTimer = new FLabel.Builder().text("\u2014").fontSize(14).fontStyle(Font.BOLD).fontAlign(javax.swing.SwingConstants.LEFT).build(); + private final FButton btnNewEvent = new FButton(Localizer.getInstance().getMessage("lblNetworkNewEventButton")); + private final FLabel btnDismissEvent = new FLabel.Builder().icon(FSkin.getIcon(FSkinProp.ICO_CLOSE)).iconInBackground(false).hoverable(true).tooltip(Localizer.getInstance().getMessage("lblNetworkDismissEventTooltip")).build(); + private final FCheckBox cbDeckConformance = new FCheckBox(Localizer.getInstance().getMessage("lblNetworkDeckFilter")); + + // Split panel for right side in Draft/Sealed mode + private final FPanel eventRightPanel = new FPanel(new MigLayout("insets 0, gap 0, wrap, fill")); + + // Action buttons for Draft/Sealed mode + private final FButton btnStartEvent = new FButton(Localizer.getInstance().getMessage("lblNetworkStartDraft")); + private final FButton btnStartMatch = new FButton(Localizer.getInstance().getMessage("lblNetworkStartMatch")); + + // (network draft state lives in CLobby) + // CTR public VLobby(final GameLobby lobby) { this.lobby = lobby; + // Create controller first — VLobby.update() and render methods rely on a non-null + // controller. External callers (e.g. CSubmenuOnlineLobby) pick up the same instance + // via view.getController(). + new CLobby(this); lblTitle.setBackground(FSkin.getColor(FSkin.Colors.CLR_THEME2)); + if (lobby.isAllowNetworking()) { + cboModePanel.addActionListener(e -> controller.onModeChanged()); + // Set a larger font on the combo box to match/exceed the variants label + for (final Component c : cboModePanel.getComponents()) { + c.setFont(FSkin.getBoldFont(14).getBaseFont()); + } + constructedFrame.add(cboModePanel, "w 100%, h 28px!, gapbottom 10px, spanx 2, wrap"); + + eventRightPanel.setOpaque(false); + eventConfigPanel.setOpaque(true); + eventConfigPanel.setBackground(FSkin.getColor(FSkin.Colors.CLR_THEME2).stepColor(20).getColor()); + eventConfigPanel.setLayout(new MigLayout( + "insets 10 14 10 14, gap 14 8, wrap 2, hidemode 3", + "[110px!][grow,fill]")); + + // Muted caption color derived from CLR_TEXT so it degrades with the theme + java.awt.Color captionColor = FSkin.getColor(FSkin.Colors.CLR_TEXT).stepColor(-80).getColor(); + lblEventFormatCaption.setForeground(captionColor); + lblEventProductCaption.setForeground(captionColor); + lblEventPickTimerCaption.setForeground(captionColor); + lblEventDateCaption.setForeground(captionColor); + lblEventStatus.setForeground(captionColor); + + // Row 1: title (+ X dismiss for host). Nested panel so hiding the X doesn't let + // MigLayout's wrap-count logic drop the status label onto this row. + final JPanel titleRow = new JPanel(new MigLayout("insets 0, fillx")); + titleRow.setOpaque(false); + titleRow.add(lblEventPanelTitle, "growx, pushx"); + if (lobby.hasControl()) { + btnDismissEvent.setCommand(() -> controller.onDismissEvent()); + titleRow.add(btnDismissEvent, "w 24px!, h 24px!, align right"); + } + eventConfigPanel.add(titleRow, "span 2, growx, wrap"); + + // Row 2: centered status message (shown only when no event exists) + eventConfigPanel.add(lblEventStatus, "span 2, align center, wrap, gapbottom 4"); + + // Rows 3-6: caption | value pairs + eventConfigPanel.add(lblEventFormatCaption); + eventConfigPanel.add(lblEventFormat, "wrap"); + eventConfigPanel.add(lblEventProductCaption); + eventConfigPanel.add(lblEventProduct, "wrap"); + eventConfigPanel.add(lblEventPickTimerCaption); + eventConfigPanel.add(lblEventPickTimer, "wrap"); + eventConfigPanel.add(lblEventDateCaption); + eventConfigPanel.add(lblEventDate, "wrap, gapbottom 6"); + + // Row 7: filter checkbox + cbDeckConformance.setSelected(true); + if (lobby.hasControl()) { + cbDeckConformance.addActionListener(e -> controller.onConformanceChanged()); + } else { + cbDeckConformance.setEnabled(false); + } + eventConfigPanel.add(cbDeckConformance, "span 2, wrap"); + + updateEventPanelState(); + } + //////////////////////////////////////////////////////// //////////////////// Variants Panel //////////////////// ImmutableList vntBoxes = null; @@ -167,7 +249,6 @@ public VLobby(final GameLobby lobby) { //////////////////////////////////////////////////////// ////////////////////// Deck Panel ////////////////////// - populateVanguardLists(); for (int i = 0; i < MAX_PLAYERS; i++) { buildDeckPanels(i); @@ -188,6 +269,19 @@ public VLobby(final GameLobby lobby) { } }); } + if (lobby.isAllowNetworking() && lobby.hasControl()) { + btnStartEvent.setFont(FSkin.getRelativeFont(18)); + btnStartEvent.addActionListener(e -> controller.startEvent()); + btnStartMatch.setFont(FSkin.getRelativeFont(18)); + btnStartMatch.addActionListener(arg0 -> { + Runnable startGame = lobby.startGame(); + if (startGame != null) { + startGame.run(); + } + }); + btnNewEvent.setFont(FSkin.getRelativeFont(18)); + btnNewEvent.addActionListener(e -> controller.openEventConfigDialog()); + } String defaultGamesInMatch = FModel.getPreferences().getPref(FPref.UI_MATCHES_PER_GAME); if (defaultGamesInMatch == null || defaultGamesInMatch.isEmpty()) { defaultGamesInMatch = "3"; @@ -244,6 +338,9 @@ public void update(final boolean fullUpdate) { activePlayersNum = lobby.getNumberOfSlots(); addPlayerBtn.setEnabled(activePlayersNum < MAX_PLAYERS); + controller.syncModeFromHost(); + controller.onLobbyDataChanged(); + final boolean allowNetworking = lobby.isAllowNetworking(); ImmutableList vntBoxes = null; @@ -326,17 +423,58 @@ public void update(final boolean fullUpdate) { if (playerWithFocus >= activePlayersNum) { changePlayerFocus(activePlayersNum - 1); } else { - populateDeckPanel(lobby.getGameType()); + updateRightPanelForMode(); } refreshPanels(true, true); } + public void setController(final CLobby controller) { + this.controller = controller; + } + + public CLobby getController() { + return controller; + } + + GameLobby getLobby() { + return lobby; + } + + String getCurrentModeSelection() { + return cboModePanel.getSelectedItem(); + } + + int getCurrentModeIndex() { + return cboModePanel.getSelectedIndex(); + } + + void setCurrentModeIndex(int idx) { + cboModePanel.setSelectedIndex(idx); + } + + void refreshConstructedFrame() { + constructedFrame.revalidate(); + constructedFrame.repaint(); + } + + boolean getConformanceSelected() { + return cbDeckConformance.isSelected(); + } + + void setConformanceSelected(boolean selected) { + cbDeckConformance.setSelected(selected); + } + public void setPlayerChangeListener(final IPlayerChangeListener listener) { this.playerChangeListener = listener; } void setReady(final int index, final boolean ready) { - if (ready && decks[index] == null && !vntMomirBasic.isSelected() && !vntMoJhoSto.isSelected()) { + // Limited mode: deck is produced by the draft/sealed flow (no pre-selection + // required when starting a new event) or is selected from the filtered event + // deck list when running a match from a past event. Skip the generic check. + boolean deckRequired = !controller.isLimitedMode(); + if (ready && deckRequired && decks[index] == null && !vntMomirBasic.isSelected() && !vntMoJhoSto.isSelected()) { SOptionPane.showErrorDialog("Select a deck before readying!"); update(false); return; @@ -676,11 +814,149 @@ void changePlayerFocus(final int newFocusOwner, final GameType gType) { newFocus.setFocused(true); playersScroll.getViewport().scrollRectToVisible(newFocus.getBounds()); - populateDeckPanel(gType); + updateRightPanelForMode(); refreshPanels(true, true); } + void setVariantsVisible(boolean visible) { + Container scrollPane = variantsPanel.getParent(); + while (scrollPane != null && !(scrollPane instanceof JScrollPane)) { + scrollPane = scrollPane.getParent(); + } + if (scrollPane != null) { + scrollPane.setVisible(visible); + } + } + + void updateRightPanelForMode() { + decksFrame.removeAll(); + if (!controller.isLimitedMode()) { + populateDeckPanel(lobby.getGameType()); + } else { + eventRightPanel.removeAll(); + eventRightPanel.add(eventConfigPanel, "w 100%, growx, gapbottom 10px, wrap"); + + if (playerWithFocus < playerPanels.size() && lobby.mayEdit(playerWithFocus)) { + final FDeckChooser chooser = getDeckChooser(playerWithFocus); + if (chooser != null) { + eventRightPanel.add(chooser, "w 100%, h 100%, grow, push"); + } + } + + decksFrame.add(eventRightPanel, "w 100%, h 100%, growy, pushy"); + + if (lobby.hasControl()) { + controller.scanAvailableEvents(); + } + updateDeckListFilter(); + } + decksFrame.revalidate(); + decksFrame.repaint(); + } + + void updateActionButtons() { + final boolean isLimited = controller.isLimitedMode(); + + // Rebuild pnlStart layout + pnlStart.removeAll(); + pnlStart.setOpaque(false); + if (lobby.hasControl()) { + if (isLimited) { + pnlStart.setLayout(new MigLayout("insets 0, gap 0")); + final String label = (controller.getConfiguredFormat() == EventFormat.SEALED) + ? localizer.getMessage("lblNetworkGeneratePools") + : localizer.getMessage("lblNetworkStartDraft"); + btnStartEvent.setText(label); + boolean isExistingEvent = controller.getActiveEventId() != null; + btnStartEvent.setEnabled(controller.getConfiguredFormat() != null && !isExistingEvent); + btnStartMatch.setEnabled(isExistingEvent); + final String eventBtn = "w " + EVENT_BTN_WIDTH + "px!, h " + EVENT_BTN_HEIGHT + "px!"; + pnlStart.add(btnNewEvent, "cell 0 0, " + eventBtn + ", gapright 20"); + pnlStart.add(btnStartEvent, "cell 1 0, " + eventBtn + ", gapright 20"); + pnlStart.add(btnStartMatch, "cell 2 0, " + eventBtn); + pnlStart.add(gamesInMatchFrame, "cell 2 1, align center"); + } else { + // Constructed mode: Start button centered with games-in-match below + pnlStart.setLayout(new MigLayout("insets 0, gap 0, wrap 2")); + pnlStart.add(btnStart, "align center, spanx 2, wrap"); + pnlStart.add(gamesInMatchFrame, "spanx 2, align center"); + } + } + // Non-host: nothing to show here — match controls are host-only. + pnlStart.revalidate(); + pnlStart.repaint(); + } + + /** Render the event panel from pre-computed contents. No decisions live here. */ + void setEventPanelContents(CLobby.EventPanelContents c) { + lblEventStatus.setText(c.statusText()); + lblEventStatus.setVisible(!c.statusText().isEmpty()); + lblEventFormat.setText(c.formatText()); + lblEventProduct.setText(c.productText()); + lblEventPickTimer.setText(c.timerText()); + lblEventDate.setText(c.dateText()); + if (lobby.hasControl()) { + btnDismissEvent.setVisible(c.showDismissX()); + } + cbDeckConformance.setVisible(c.showConformance()); + cbDeckConformance.setEnabled(c.conformanceEnabled()); + eventConfigPanel.revalidate(); + eventConfigPanel.repaint(); + } + + /** Delegator kept for existing call sites; prefer controller.refreshEventPanel(). */ + void updateEventPanelState() { + controller.refreshEventPanel(); + } + + void updateDeckListFilter() { + if (!controller.isLimitedMode()) return; + if (playerWithFocus >= playerPanels.size() || !lobby.mayEdit(playerWithFocus)) return; + + final FDeckChooser chooser = getDeckChooser(playerWithFocus); + if (chooser == null) return; + + if (chooser.getSelectedDeckType() != DeckType.NET_EVENT_DECK) { + chooser.setSelectedDeckType(DeckType.NET_EVENT_DECK); + } + + // Re-read pools from disk so edits made in the deck editor are reflected. + FModel.getDecks().reloadNetworkEventDecks(); + + final String activeEventId = controller.getActiveEventId(); + List allDecks; + if (activeEventId == null) { + // No event loaded — there are no valid decks for a limited match yet. + allDecks = new ArrayList<>(); + } else if (controller.isActiveConformance()) { + allDecks = new ArrayList<>(DeckProxy.getAllNetworkEventDecks()); + allDecks.removeIf(dp -> { + Deck d = dp.getDeck(); + return d == null || !activeEventId.equals(DeckProxy.getEventTag(d, "eventId")); + }); + } else { + allDecks = new ArrayList<>(DeckProxy.getAllNetworkEventDecks()); + } + + // Preserve the user's current pick across pool rebuilds. Match by deck name — + // reloadNetworkEventDecks() rebuilds DeckProxy instances so reference equality + // would fail on every lobby update, silently resetting selection. + DeckProxy previouslySelected = chooser.getLstDecks().getSelectedItem(); + String prevName = (previouslySelected != null && previouslySelected.getDeck() != null) + ? previouslySelected.getDeck().getName() : null; + chooser.getLstDecks().setPool(allDecks); + chooser.getLstDecks().setup(ItemManagerConfig.NET_EVENT_DECKS); + if (prevName != null) { + for (DeckProxy dp : allDecks) { + if (dp.getDeck() != null && prevName.equals(dp.getDeck().getName())) { + chooser.getLstDecks().setSelectedItem(dp); + break; + } + } + } + } + /** Saves avatar prefs for players one and two. */ void updateAvatarPrefs() { final int pOneIndex = getPlayerPanel(0).getAvatarIndex(); @@ -905,4 +1181,29 @@ public void updateVanguardList(final int playerIndex) { vgdList.setSelectedIndex(0); } } + + @Override + public void onDraftPackArrived(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { + controller.onDraftPackArrived(seatIndex, pack, packNumber, pickNumber, timerDurationSeconds); + } + + @Override + public void onDraftSeatPicked(int seatIndex, int[] seatQueueDepths) { + controller.onDraftSeatPicked(seatIndex, seatQueueDepths); + } + + @Override + public void onDraftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack) { + controller.onDraftAutoPicked(seatIndex, card, packNumber, pickInPack); + } + + public void cancelActiveDraft() { + controller.cancelActiveDraft(); + } + + @Override + public void onReceiveEventPool(String eventId, Deck pool) { + controller.onReceiveEventPool(eventId, pool); + } } diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineDecks.java b/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineDecks.java new file mode 100644 index 00000000000..da6d088ff40 --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineDecks.java @@ -0,0 +1,60 @@ +package forge.screens.home.online; + +import forge.Singletons; +import forge.deck.Deck; +import forge.deck.DeckProxy; +import forge.gui.framework.FScreen; +import forge.gui.framework.ICDoc; +import forge.itemmanager.ItemManagerConfig; +import forge.model.FModel; +import forge.screens.deckeditor.CDeckEditorUI; +import forge.screens.deckeditor.SEditorIO; +import forge.screens.deckeditor.controllers.CEditorLimited; + +/** + * Controls the online draft/sealed decks submenu in the home UI. + * + *

(C at beginning of class name denotes a control class.) + */ +public enum CSubmenuOnlineDecks implements ICDoc { + SINGLETON_INSTANCE; + + @Override + public void register() { + } + + @Override + public void initialize() { + final VSubmenuOnlineDecks view = VSubmenuOnlineDecks.SINGLETON_INSTANCE; + // Override the default editDeck command so it uses getNetworkEventDecks() + // instead of the default getSealed() that DeckManager would pick + view.getLstDecks().setItemActivateCommand(() -> { + final DeckProxy deck = view.getLstDecks().getSelectedItem(); + final FScreen screen = FScreen.DECK_EDITOR_SEALED; + final CEditorLimited editorCtrl = new CEditorLimited<>( + FModel.getDecks().getNetworkEventDecks(), Deck::new, screen, + CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); + + if (!Singletons.getControl().ensureScreenActive(screen)) { + return; + } + // Confirm before installing the new controller so a Cancel doesn't + // clobber the previous editor's unsaved state. + if (!SEditorIO.confirmSaveChanges(screen, true)) { + return; + } + CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(editorCtrl); + if (deck != null) { + CDeckEditorUI.SINGLETON_INSTANCE.getCurrentEditorController() + .getDeckController().load(deck.getPath(), deck.getName()); + } + }); + } + + @Override + public void update() { + final VSubmenuOnlineDecks view = VSubmenuOnlineDecks.SINGLETON_INSTANCE; + view.getLstDecks().setPool(DeckProxy.getAllNetworkEventDecks()); + view.getLstDecks().setup(ItemManagerConfig.NET_EVENT_DECKS); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineLobby.java b/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineLobby.java index 6f66db4e75c..1170ddf8bee 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineLobby.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/online/CSubmenuOnlineLobby.java @@ -49,7 +49,7 @@ public enum CSubmenuOnlineLobby implements ICDoc, IMenuProvider { private CLobby lobby; void setLobby(final VLobby lobbyView) { - lobby = new CLobby(lobbyView); + lobby = lobbyView.getController(); initialize(); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/online/OnlineMenu.java b/forge-gui-desktop/src/main/java/forge/screens/home/online/OnlineMenu.java index 3d2aabc3a39..244d36b3cce 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/online/OnlineMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/online/OnlineMenu.java @@ -10,6 +10,7 @@ import javax.swing.JMenuItem; import javax.swing.JSeparator; +import forge.gui.FDraftOverlay; import forge.gui.FNetOverlay; import forge.localinstance.properties.ForgeConstants; import forge.util.Localizer; @@ -27,10 +28,12 @@ public static JMenu getMenu() { menu.add(getMenuItem_OpenNetworkLogs()); menu.add(new JSeparator()); menu.add(chatItem); + menu.add(draftItem); return menu; } public static final JCheckBoxMenuItem chatItem; + public static final JCheckBoxMenuItem draftItem; static { chatItem = new JCheckBoxMenuItem(Localizer.getInstance().getMessage("lblShowChatPanel")); @@ -42,6 +45,15 @@ public static JMenu getMenu() { FNetOverlay.SINGLETON_INSTANCE.hide(); } }); + draftItem = new JCheckBoxMenuItem(Localizer.getInstance().getMessage("lblShowDraftPanel")); + draftItem.addActionListener(e -> { + if (((JMenuItem)e.getSource()).isSelected()) { + FDraftOverlay.SINGLETON_INSTANCE.show(); + } + else { + FDraftOverlay.SINGLETON_INSTANCE.hide(); + } + }); } private static JMenuItem getMenuItem_HostGame() { diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineDecks.java b/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineDecks.java new file mode 100644 index 00000000000..ad45036683c --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineDecks.java @@ -0,0 +1,95 @@ +package forge.screens.home.online; + +import forge.game.GameType; +import forge.gui.framework.DragCell; +import forge.gui.framework.DragTab; +import forge.gui.framework.EDocID; +import forge.itemmanager.DeckManager; +import forge.itemmanager.ItemManagerContainer; +import forge.screens.deckeditor.CDeckEditorUI; +import forge.screens.home.EMenuGroup; +import forge.screens.home.IVSubmenu; +import forge.screens.home.LblHeader; +import forge.screens.home.VHomeUI; +import forge.screens.home.VHomeUI.PnlDisplay; +import forge.toolbox.FSkin; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; + +/** + * Assembles Swing components of the online event decks submenu singleton. + * + *

(V at beginning of class name denotes a view class.) + */ +public enum VSubmenuOnlineDecks implements IVSubmenu { + /** */ + SINGLETON_INSTANCE; + + // Fields used with interface IVDoc + private DragCell parentCell; + private final DragTab tab = new DragTab(Localizer.getInstance().getMessage("lblNetEventDecks")); + + private final LblHeader lblTitle = new LblHeader(Localizer.getInstance().getMessage("lblNetEventDecks")); + + private final DeckManager lstDecks = new DeckManager(GameType.Sealed, CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); + + VSubmenuOnlineDecks() { + lblTitle.setBackground(FSkin.getColor(FSkin.Colors.CLR_THEME2)); + } + + @Override + public EMenuGroup getGroupEnum() { + return EMenuGroup.ONLINE; + } + + @Override + public String getMenuTitle() { + return Localizer.getInstance().getMessage("lblNetEventDecks"); + } + + @Override + public EDocID getItemEnum() { + return EDocID.HOME_NET_DECKS; + } + + public DeckManager getLstDecks() { + return lstDecks; + } + + @Override + public void populate() { + PnlDisplay pnlDisplay = VHomeUI.SINGLETON_INSTANCE.getPnlDisplay(); + pnlDisplay.removeAll(); + pnlDisplay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax right")); + pnlDisplay.add(lblTitle, "w 80%!, h 40px!, gap 0 0 15px 15px, ax right"); + pnlDisplay.add(new ItemManagerContainer(lstDecks), "w 80%!, gap 0 10% 0 60px, pushy, growy"); + + pnlDisplay.repaint(); + pnlDisplay.revalidate(); + } + + @Override + public EDocID getDocumentID() { + return EDocID.HOME_NET_DECKS; + } + + @Override + public DragTab getTabLabel() { + return tab; + } + + @Override + public void setParentCell(final DragCell cell0) { + this.parentCell = cell0; + } + + @Override + public DragCell getParentCell() { + return parentCell; + } + + @Override + public CSubmenuOnlineDecks getLayoutControl() { + return CSubmenuOnlineDecks.SINGLETON_INSTANCE; + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineLobby.java b/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineLobby.java index 1a80d2b0807..cf41d5ed51c 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineLobby.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/online/VSubmenuOnlineLobby.java @@ -72,6 +72,10 @@ public void setClient(final FGameClient client) { this.client = client; } + public FGameClient getClient() { + return this.client; + } + @Override public void populate() { final JPanel container = VHomeUI.SINGLETON_INSTANCE.getPnlDisplay(); @@ -165,7 +169,7 @@ public void populate() { fdc.getDecksComboBox().addListener(ev -> lobby.focusOnAvatar()); } - container.add(lobby.getConstructedFrame(), "gap 20px 20px 20px 0px, push, grow"); + container.add(lobby.getConstructedFrame(), "gap 20px 20px 10px 0px, push, grow"); container.add(lobby.getPanelStart(), "gap 0 0 3.5%! 3.5%!, ax center"); if (container.isShowing()) { @@ -234,6 +238,7 @@ public boolean onClosing(final FScreen screen) { if (SOptionPane.showConfirmDialog(Localizer.getInstance().getMessage("lblLeaveLobbyDescription"), Localizer.getInstance().getMessage("lblLeave"))) { server.stopServer(); FNetOverlay.SINGLETON_INSTANCE.reset(); + if (lobby != null) lobby.cancelActiveDraft(); return true; } } else if (client == null || SOptionPane.showConfirmDialog(Localizer.getInstance().getMessage("lblLeaveLobbyConfirm"), Localizer.getInstance().getMessage("lblLeave"))) { @@ -242,6 +247,7 @@ public boolean onClosing(final FScreen screen) { client = null; } FNetOverlay.SINGLETON_INSTANCE.reset(); + if (lobby != null) lobby.cancelActiveDraft(); return true; } return false; diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuConstructed.java b/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuConstructed.java index 668526600a9..e3511cc45a9 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuConstructed.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuConstructed.java @@ -21,7 +21,7 @@ public enum CSubmenuConstructed implements ICDoc, IMenuProvider { SINGLETON_INSTANCE; private final VSubmenuConstructed view = VSubmenuConstructed.SINGLETON_INSTANCE; - private final CLobby lobby = new CLobby(view.getLobby()); + private final CLobby lobby = view.getLobby().getController(); @Override public void register() { diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuSealed.java b/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuSealed.java index 2045dc55b2d..08a76ac6626 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuSealed.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/sanctioned/CSubmenuSealed.java @@ -10,7 +10,6 @@ import forge.Singletons; import forge.deck.Deck; -import forge.deck.DeckBase; import forge.deck.DeckGroup; import forge.deck.DeckProxy; import forge.game.GameType; @@ -22,13 +21,11 @@ import forge.gui.UiCommand; import forge.gui.framework.FScreen; import forge.gui.framework.ICDoc; -import forge.item.InventoryItem; import forge.itemmanager.ItemManagerConfig; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.player.GamePlayerUtil; import forge.screens.deckeditor.CDeckEditorUI; -import forge.screens.deckeditor.controllers.ACEditorBase; import forge.screens.deckeditor.controllers.CEditorLimited; import forge.toolbox.FOptionPane; @@ -145,17 +142,16 @@ private void startGame(final GameType gameType) { SwingUtilities.invokeLater(SOverlayUtils::hideOverlay); } - @SuppressWarnings("unchecked") - private void setupSealed() { + private void setupSealed() { final DeckGroup sealed = SealedCardPoolGenerator.generateSealedDeck(false); if (sealed == null) { return; } - final ACEditorBase editor = (ACEditorBase) new CEditorLimited( - FModel.getDecks().getSealed(), FScreen.DECK_EDITOR_SEALED, CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); + final CEditorLimited editor = new CEditorLimited<>( + FModel.getDecks().getSealed(), DeckGroup::new, FScreen.DECK_EDITOR_SEALED, CDeckEditorUI.SINGLETON_INSTANCE.getCDetailPicture()); Singletons.getControl().setCurrentScreen(FScreen.DECK_EDITOR_SEALED); CDeckEditorUI.SINGLETON_INSTANCE.setEditorController(editor); - editor.getDeckController().setModel((T) sealed); + editor.getDeckController().setModel(sealed); } private void fillOpponentComboBox() { diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java b/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java index 18dbbb56fe1..7f4e79c63b5 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/GameLogPanel.java @@ -1,6 +1,7 @@ package forge.screens.match; import java.awt.AWTEvent; +import java.awt.Component; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Graphics; @@ -137,6 +138,14 @@ public void addLogEntry(final String text) { addLogEntry(text, null, null); } + public void addLogEntry(final String text, final java.awt.Color foreground) { + addLogEntry(text, null, null); + if (foreground != null) { + final Component[] kids = scrollablePanel.getComponents(); + if (kids.length > 0) kids[kids.length - 1].setForeground(foreground); + } + } + public void addLogEntry(final String text, final CardView card, final Iterable viewers) { final boolean useAlternateBackColor = (scrollablePanel.getComponents().length % 2 == 0); final boolean showCardImages = FModel.getPreferences().getPrefBoolean(FPref.UI_LOG_SHOW_CARD_IMAGES); diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/FComboBoxPanel.java b/forge-gui-desktop/src/main/java/forge/toolbox/FComboBoxPanel.java index dde9c1ab5af..fffc22b53e1 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/FComboBoxPanel.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/FComboBoxPanel.java @@ -111,6 +111,14 @@ public E getSelectedItem() { return comboBox.getSelectedItem(); } + public int getSelectedIndex() { + return comboBox.getSelectedIndex(); + } + + public void setSelectedIndex(final int index) { + comboBox.setSelectedIndex(index); + } + private void refreshSkin() { comboBox = FComboBoxWrapper.refreshComboBoxSkin(comboBox); } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 2aee7e96d68..4effaf8ac42 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -760,6 +760,7 @@ lblNetArchiveModernDecks=Net Archive Modern Decks lblNetArchiveLegacyDecks=Net Archive Legacy Decks lblNetArchiveVintageDecks=Net Archive Vintage Decks lblNetArchiveBlockDecks=Net Archive Block Decks +lblNetEventDecks=Draft/Sealed Decks lblNetArchivePauperDecks=Net Archive Pauper Decks #VSubmenuTutorial lblTutorial=Tutorial @@ -1072,6 +1073,9 @@ lblCard=Card #CardManager.java lblFormat=Format lblFormats=Formats +lblEventType=Event Type +lblEventDate=Date +lblProduct=Product lblQuestWorld=Quest World lblBlock=Block lblSets=Sets @@ -2944,6 +2948,62 @@ lblOnline=Online lblShowChatPanel=Show Chat Panel lblOpenNetworkLogs=Open Network Logs lblDisconnect=Disconnect +lblShowDraftPanel=Show Draft Panel +#VLobby.java (network draft/sealed) +lblNetworkLobbyMode=Mode: +lblNetworkModeConstructed=Constructed +lblNetworkModeLimited=Limited +lblNetworkModeDraft=Draft +lblNetworkModeSealed=Sealed +lblNetworkWaitingForHost=Waiting for host to select or create an event... +lblNetworkDeckFilter=Only allow decks from this event +lblNetworkNewEventButton=Set Up Event +lblNetworkSetUpEventPrompt=Create new or load a past event? +lblNetworkSetUpEventCreate=Create new +lblNetworkSetUpEventLoadPast=Load past event +lblNetworkLoadPastEventPrompt=Choose a past event: +lblNetworkNoEventSelected=(none selected) +lblNetworkNewEventNotDrafted=New event (not yet drafted) +lblNetworkNewEventNoPools=New event (pools not yet generated) +lblNetworkEventDetailsTitle=Limited Event Details +lblNetworkFormatCaption=Format +lblNetworkProductCaption=Product +lblNetworkPickTimerCaption=Pick timer +lblNetworkDateCaption=Date +lblNetworkPickTimerNotApplicable=N/A +lblNetworkDismissEventTooltip=Discard the configured event +lblNetworkChooseEventType=Choose Event Type +lblNetworkStartDraft=Start Draft +lblNetworkGeneratePools=Generate Pools +lblNetworkStartMatch=Start Match +lblNetworkNoEventConfigured=No event configured. Select Draft or Sealed mode first. +lblNetworkFailedDraft=Failed to create draft. +lblNetworkChooseDraftFormat=Choose draft format: +lblNetworkPickTimerPrompt=Pick timer (seconds per pick): +lblNetworkGraceTimerPromptLine1=Grace period on disconnect +lblNetworkGraceTimerPromptLine2=(seconds, 0 to disable): +lblNetworkDraftTimersTitle=Draft Timers +lblDraftCompletePoolSaved=Draft complete! Your pool has been saved as ''{0}''. +#FDraftOverlay.java +lblDraftOverlayPackOfN=Pack {0} of {1} +lblDraftOverlayPickOfN=Pick {0} of {1} +lblDraftOverlayWaitingForPack=Waiting for pack... +lblDraftOverlayTimer=Timer: {0} +lblDraftOverlayYou=YOU +lblSeatN=Seat {0} +#NetworkDraftLog.java +lblDraftLogDraftStarted=Draft started -- {0} players +lblDraftLogPacksOf={0} packs of {1} +lblDraftLogPlayersYou=Players: You +lblDraftLogAiSeats=AI seats: +lblDraftLogPackHeader=-- Pack {0} -- {1} ------ +lblDraftLogPassingRight=passing right +lblDraftLogPassingLeft=passing left +lblDraftLogOtherPick={0} picked +lblDraftLogMyPick=You picked: {0} (pack {1}, pick {2}) +lblDraftLogWaiting=[{0} waiting] +lblDraftLogDraftComplete=Draft complete -- {0} cards +lblDraftLogBuildingDeck=Building deck... #CardOverlaysMenu.java lblCardName=Card Name lblPowerOrToughness=Power/Toughness diff --git a/forge-gui/src/main/java/forge/deck/DeckProxy.java b/forge-gui/src/main/java/forge/deck/DeckProxy.java index bb8c91b135e..c8c8392929a 100644 --- a/forge-gui/src/main/java/forge/deck/DeckProxy.java +++ b/forge-gui/src/main/java/forge/deck/DeckProxy.java @@ -344,6 +344,16 @@ public PaperCard getHighestCMCCard() { return key; } + public static String getEventTag(Deck deck, String key) { + String prefix = key + ":"; + for (String tag : deck.getTags()) { + if (tag.startsWith(prefix)) { + return tag.substring(prefix.length()); + } + } + return null; + } + public Set getFormats() { if (formats == null) { formats = FModel.getFormats().getAllFormatsOfDeck(getDeck()); @@ -696,6 +706,15 @@ public static List getAllDraftDecks() { return decks; } + public static List getAllNetworkEventDecks() { + final List decks = new ArrayList<>(); + final IStorage networkEvent = FModel.getDecks().getNetworkEventDecks(); + for (final Deck d : networkEvent) { + decks.add(new DeckProxy(d, "Event", GameType.Draft, networkEvent)); + } + return decks; + } + @SuppressWarnings("unchecked") public static List getWinstonDecks(final IStorage draft) { final List decks = new ArrayList<>(); diff --git a/forge-gui/src/main/java/forge/deck/DeckType.java b/forge-gui/src/main/java/forge/deck/DeckType.java index 2f20910ea2d..a4d7beb47b2 100644 --- a/forge-gui/src/main/java/forge/deck/DeckType.java +++ b/forge-gui/src/main/java/forge/deck/DeckType.java @@ -40,7 +40,8 @@ public enum DeckType { NET_ARCHIVE_PAUPER_DECK("lblNetArchivePauperDecks"), NET_ARCHIVE_LEGACY_DECK("lblNetArchiveLegacyDecks"), NET_ARCHIVE_VINTAGE_DECK("lblNetArchiveVintageDecks"), - NET_ARCHIVE_BLOCK_DECK("lblNetArchiveBlockDecks"); + NET_ARCHIVE_BLOCK_DECK("lblNetArchiveBlockDecks"), + NET_EVENT_DECK("lblNetEventDecks"); public static DeckType[] ConstructedOptions; public static DeckType[] CommanderOptions; @@ -65,6 +66,7 @@ public enum DeckType { DeckType.THEME_DECK, DeckType.RANDOM_DECK, DeckType.NET_DECK, + DeckType.NET_EVENT_DECK, DeckType.NET_ARCHIVE_STANDARD_DECK, DeckType.NET_ARCHIVE_PIONEER_DECK, DeckType.NET_ARCHIVE_MODERN_DECK, @@ -85,6 +87,7 @@ public enum DeckType { DeckType.THEME_DECK, DeckType.RANDOM_DECK, DeckType.NET_DECK, + DeckType.NET_EVENT_DECK, DeckType.NET_ARCHIVE_STANDARD_DECK, DeckType.NET_ARCHIVE_PIONEER_DECK, DeckType.NET_ARCHIVE_MODERN_DECK, diff --git a/forge-gui/src/main/java/forge/gamemodes/limited/BoosterDraft.java b/forge-gui/src/main/java/forge/gamemodes/limited/BoosterDraft.java index 5dec790d6c4..21953a0be42 100644 --- a/forge-gui/src/main/java/forge/gamemodes/limited/BoosterDraft.java +++ b/forge-gui/src/main/java/forge/gamemodes/limited/BoosterDraft.java @@ -58,12 +58,14 @@ public class BoosterDraft implements IBoosterDraft { int podSize; private final List players = new ArrayList<>(); - private final LimitedPlayer localPlayer; + private LimitedPlayer localPlayer; private boolean readyForComputerPick = false; private IDraftLog draftLog = null; private boolean shouldShowDraftLog = false; + private boolean forNetwork = false; + private String productName; private DraftOptions.DoublePick doublePickDuringDraft; protected int nextBoosterGroup = 0; @@ -88,6 +90,23 @@ public static BoosterDraft createDraft(final LimitedPoolType draftType) { return draft; } + /** + * Create a draft for network play. Product is generated but boosters are NOT + * initialized — the caller must configure pod size and human seats, then call + * {@link #initializeBoosters()} manually. + * + * @param draftType the draft pool type + * @return a partially-initialized draft, or null if product generation fails + */ + public static BoosterDraft createDraftForNetwork(final LimitedPoolType draftType) { + final BoosterDraft draft = new BoosterDraft(draftType); + draft.forNetwork = true; + if (!draft.generateProduct()) { + return null; + } + return draft; + } + protected boolean generateProduct() { switch (this.draftFormat) { case Full: // Draft from all cards in Forge @@ -107,8 +126,14 @@ protected boolean generateProduct() { ? FModel.getBlocks() : FModel.getFantasyBlocks(); + // TODO Conspiracy blocks gated for network draft: pack-effect prompts + // (Agent of Acquisitions, Cogwork Librarian, etc.) pop on the host, the + // conspiracy player-flag state isn't replicated to clients, and the draft + // log isn't shipped over the wire. Custom/Chaos/Import paths can still + // smuggle CNS cards in — fix those when the underlying issues are resolved. for (final CardBlock b : storage) { if (b.getCntBoostersDraft() > 0) { + if (forNetwork && b.getName().contains("Conspiracy")) continue; blocks.add(b); } } @@ -117,6 +142,7 @@ protected boolean generateProduct() { if (block == null) { return false; } + this.productName = block.getName(); final List cardSets = block.getSets(); final Stack sets = new Stack<>(); @@ -151,6 +177,7 @@ protected boolean generateProduct() { return false; } + this.productName = block.getName() + " (" + p + ")"; final String[] pp = p.toString().split("/"); for (int i = 0; i < nPacks; i++) { this.product.add(block.getBooster(pp[i])); @@ -158,6 +185,7 @@ protected boolean generateProduct() { } else { // Only one set is chosen. If that set lets you draft 2 cards to start adjust draft settings now String setCode = sets.get(0); + this.productName = block.getName() + " (" + setCode + ")"; CardEdition edition = FModel.getMagicDb().getEditions().get(setCode); // If this is metaset, edtion will be null if (edition != null) { @@ -192,6 +220,7 @@ protected boolean generateProduct() { return false; } + this.productName = customDraft.getName(); this.setupCustomDraft(customDraft); } break; @@ -217,6 +246,7 @@ protected boolean generateProduct() { if (theme == null) { return false; // abort if no theme is selected } + this.productName = theme.getLabel(); // Filter all sets by theme restrictions final Predicate themeFilter = theme.getEditionFilter(); final CardEdition.Collection allEditions = StaticData.instance().getEditions(); @@ -260,6 +290,7 @@ protected boolean generateProduct() { SOptionPane.showErrorDialog(Localizer.getInstance().getMessage("lblFailedToImportCube") + ": " + inputCubeId); return false; } + this.productName = importedDraft.getName(); this.setupCustomDraft(importedDraft); } catch (Exception e) { SOptionPane.showErrorDialog(Localizer.getInstance().getMessage("lblErrorImportingCube") + ": " + e.getMessage()); @@ -349,6 +380,43 @@ public void setPodSize(int size) { } } + /** + * Authoritatively set which seats are human-controlled. Seats in {@code humanSeats} + * become {@link LimitedPlayer} instances; all other seats become {@link LimitedPlayerAI}. + * The constructor seeds seat 0 as a local human by default for single-player use — + * network drafts must call this to reassign according to the shuffled seat layout, + * which may place the host at a different seat. + * + *

Must be called before {@link #initializeBoosters()} so pack state is allocated + * against the final seat configuration. Only network drafts call this; single-player + * code leaves seat 0 as the default human. + * + * @param humanSeats seat indices (0-based) that should be human-controlled + */ + public void setHumanSeats(Set humanSeats) { + for (int seat = 0; seat < players.size(); seat++) { + boolean shouldBeHuman = humanSeats.contains(seat); + LimitedPlayer current = players.get(seat); + if (shouldBeHuman && current instanceof LimitedPlayerAI) { + players.set(seat, new LimitedPlayer(seat, this)); + } else if (!shouldBeHuman && !(current instanceof LimitedPlayerAI)) { + players.set(seat, new LimitedPlayerAI(seat, this)); + } + } + // Keep localPlayer consistent with whatever occupies seat 0 now. + this.localPlayer = players.get(0); + } + + /** Returns the total number of booster rounds in this draft. */ + public int getNumRounds() { + return product.size(); + } + + /** Human-readable name of the chosen block / theme / cube (null for Full). */ + public String getProductName() { + return productName; + } + public int getPodSize() { return this.podSize; } diff --git a/forge-gui/src/main/java/forge/gamemodes/limited/LimitedPlayer.java b/forge-gui/src/main/java/forge/gamemodes/limited/LimitedPlayer.java index 4f046e60e2a..810cce72b8a 100644 --- a/forge-gui/src/main/java/forge/gamemodes/limited/LimitedPlayer.java +++ b/forge-gui/src/main/java/forge/gamemodes/limited/LimitedPlayer.java @@ -103,6 +103,11 @@ public Deck getDeck() { return deck; } + /** Number of packs waiting in this player's queue. */ + public int getPackQueueSize() { + return packQueue.size(); + } + public List getRemovedFromCardPool() { return removedFromCardPool; } diff --git a/forge-gui/src/main/java/forge/gamemodes/limited/SealedCardPoolGenerator.java b/forge-gui/src/main/java/forge/gamemodes/limited/SealedCardPoolGenerator.java index 9696a744fad..1e4a8593787 100644 --- a/forge-gui/src/main/java/forge/gamemodes/limited/SealedCardPoolGenerator.java +++ b/forge-gui/src/main/java/forge/gamemodes/limited/SealedCardPoolGenerator.java @@ -69,6 +69,9 @@ public class SealedCardPoolGenerator { /** The Land set code. */ private String landSetCode = null; + /** Human-readable name of the specific block / edition / custom pool chosen (null for Full). */ + private String productName = null; + public static DeckGroup generateSealedDeck(final boolean addBasicLands) { final String prompt = Localizer.getInstance().getMessage("lblChooseSealedDeckFormat"); final LimitedPoolType poolType = SGuiChoose.oneOrNone(prompt, LimitedPoolType.values()); @@ -160,7 +163,7 @@ public static DeckGroup generateSealedDeck(final boolean addBasicLands) { * @param poolType * a {@link java.lang.String} object. */ - private SealedCardPoolGenerator(final LimitedPoolType poolType) { + public SealedCardPoolGenerator(final LimitedPoolType poolType) { switch(poolType) { case Full: // Choose number of boosters @@ -229,6 +232,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { //chosenEdition but really it should be defined by something in the edition file? landSetCode = chosenEdition.getCode(); + productName = chosenEdition.getName(); break; case Block: @@ -253,6 +257,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { sets.push(ms); } + String packSummary = null; if (sets.size() > 1 ) { final List setCombos = getSetCombos(sets, nPacks); if (setCombos == null || setCombos.isEmpty()) { @@ -262,6 +267,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { final String p = setCombos.size() > 1 ? SGuiChoose.oneOrNone(Localizer.getInstance().getMessage("lblChoosePackNumberToPlay"), setCombos) : setCombos.get(0); if (p == null) { return; } + packSummary = p; for (String pz : TextUtil.split(p, ',')) { String[] pps = TextUtil.splitWithParenthesis(pz.trim(), ' '); String setCode = pps[pps.length - 1]; @@ -272,6 +278,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { } } else { + packSummary = sets.get(0); IUnOpenedProduct prod = block.getBooster(sets.get(0)); for (int i = 0; i < nPacks; i++) { this.product.add(prod); @@ -279,6 +286,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { } landSetCode = block.getLandSet().getCode(); + productName = block.getName() + " (" + packSummary + ")"; break; case Custom: @@ -324,6 +332,7 @@ private SealedCardPoolGenerator(final LimitedPoolType poolType) { } landSetCode = draft.getLandSetCode(); + productName = draft.getName(); break; case Import: /* @@ -558,13 +567,21 @@ public CardPool getCardPool(final boolean isHuman) { /** * Gets the land set code. - * + * * @return the landSetCode */ public String getLandSetCode() { return this.landSetCode; } + /** + * Human-readable name of the specific block / edition / custom pool chosen + * during construction, or null if the pool type has no sub-selection (Full). + */ + public String getProductName() { + return this.productName; + } + public boolean isEmpty() { return product.isEmpty(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java index ddd5f92dbec..f1ec38e3701 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java @@ -14,6 +14,7 @@ import forge.game.IHasGameType; import forge.game.player.Player; import forge.game.player.RegisteredPlayer; +import forge.gamemodes.net.NetworkEventView; import forge.gamemodes.net.event.UpdateLobbyPlayerEvent; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; @@ -95,6 +96,16 @@ public LobbySlot getSlot(final int index) { } return data.slots.get(index); } + + /** First non-OPEN slot that isn't ready, or null if every filled slot is ready. */ + public LobbySlot findFirstUnreadySlot() { + for (int i = 0; i < getNumberOfSlots(); i++) { + LobbySlot slot = getSlot(i); + if (slot == null || slot.getType() == LobbySlotType.OPEN) continue; + if (!slot.isReady()) return slot; + } + return null; + } public void applyToSlot(final int index, final UpdateLobbyPlayerEvent event) { final LobbySlot slot = getSlot(index); if (slot == null || event == null) { @@ -330,7 +341,7 @@ private boolean isEnoughTeams() { return false; } - protected final void updateView(final boolean fullUpdate) { + protected void updateView(final boolean fullUpdate) { if (listener != null) { listener.update(fullUpdate); } @@ -399,9 +410,10 @@ public Runnable startGame() { //Auto-generated decks don't need to be checked here //Commander deck replaces regular deck and is checked later if (checkLegality && autoGenerateVariant == null && !isCommanderMatch) { + final DeckFormat deckFormat = data.isLimitedMode() ? DeckFormat.Limited : GameType.Constructed.getDeckFormat(); for (final LobbySlot slot : activeSlots) { final String name = slot.getName(); - final String errMsg = GameType.Constructed.getDeckFormat().getDeckConformanceProblem(slot.getDeck()); + final String errMsg = deckFormat.getDeckConformanceProblem(slot.getDeck()); if (null != errMsg) { SOptionPane.showErrorDialog(Localizer.getInstance().getMessage("lblPlayerDeckError", name, errMsg), Localizer.getInstance().getMessage("lblInvalidDeck")); return null; @@ -554,8 +566,37 @@ public final static class GameLobbyData implements Serializable { private final Set appliedVariants = EnumSet.noneOf(GameType.class); private final List slots = Lists.newArrayList(); + private NetworkEventView eventView; + private boolean limitedMode; + private String activeEventId; + private boolean activeConformance; public GameLobbyData() { } + + public NetworkEventView getEventView() { + return eventView; + } + public void setEventView(final NetworkEventView view) { + this.eventView = view; + } + public boolean isLimitedMode() { + return limitedMode; + } + public void setLimitedMode(final boolean limited) { + this.limitedMode = limited; + } + public String getActiveEventId() { + return activeEventId; + } + public void setActiveEventId(final String id) { + this.activeEventId = id; + } + public boolean isActiveConformance() { + return activeConformance; + } + public void setActiveConformance(final boolean conformance) { + this.activeConformance = conformance; + } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/EventFormat.java b/forge-gui/src/main/java/forge/gamemodes/net/EventFormat.java new file mode 100644 index 00000000000..57fd715cc6a --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/EventFormat.java @@ -0,0 +1,6 @@ +package forge.gamemodes.net; + +public enum EventFormat { + BOOSTER_DRAFT, + SEALED +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/EventParticipant.java b/forge-gui/src/main/java/forge/gamemodes/net/EventParticipant.java new file mode 100644 index 00000000000..f8d7891bc83 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/EventParticipant.java @@ -0,0 +1,64 @@ +package forge.gamemodes.net; + +import java.io.Serializable; +import java.util.List; + +import forge.util.Localizer; + +/** + * A player or AI in a network draft/sealed event. + *

+ * {@code seatIndex} is the position in the draft pod's circular pack-passing order + * (0 to podSize-1). Randomized before draft start. Used to index into BoosterDraft + * player lists and determine pack-passing neighbors. + *

+ * {@code lobbySlotIndex} is the player's position in the network lobby UI. Used to + * look up the RemoteClient for sending network messages. AI-fill seats that have no + * lobby slot use -1. + */ +public final class EventParticipant implements Serializable { + private static final long serialVersionUID = 1L; + + public enum Type { HUMAN, AI } + + private final String name; + private final Type type; + private final int seatIndex; + private final int lobbySlotIndex; + + public EventParticipant(String name, Type type, int seatIndex, int lobbySlotIndex) { + this.name = name; + this.type = type; + this.seatIndex = seatIndex; + this.lobbySlotIndex = lobbySlotIndex; + } + + public String getName() { return name; } + public Type getType() { return type; } + public int getSeatIndex() { return seatIndex; } + public int getLobbySlotIndex() { return lobbySlotIndex; } + public boolean isHuman() { return type == Type.HUMAN; } + public boolean isAI() { return type == Type.AI; } + + public static EventParticipant findBySeat(List list, int seatIndex) { + if (list == null) return null; + for (EventParticipant p : list) { + if (p.getSeatIndex() == seatIndex) return p; + } + return null; + } + + /** + * Display name for a seat, looking up first in the given view's participants then falling + * back to the current event's list. Appends " (AI)" for AI seats. If no participant found, + * returns a localized "Seat N" placeholder. + */ + public static String resolveName(int seatIndex, List viewParticipants, + List currentEventParticipants) { + Localizer localizer = Localizer.getInstance(); + EventParticipant p = findBySeat(viewParticipants, seatIndex); + if (p == null) p = findBySeat(currentEventParticipants, seatIndex); + if (p == null) return localizer.getMessage("lblSeatN", String.valueOf(seatIndex)); + return p.isAI() ? p.getName() + " (" + localizer.getMessage("lblAI") + ")" : p.getName(); + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/EventPhase.java b/forge-gui/src/main/java/forge/gamemodes/net/EventPhase.java new file mode 100644 index 00000000000..588861eea4f --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/EventPhase.java @@ -0,0 +1,7 @@ +package forge.gamemodes.net; + +public enum EventPhase { + LOBBY_GATHER, + DRAFTING, + POOL_DISTRIBUTION +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java b/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java index 4cc06a4f1fb..9fd91d1f8b9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java @@ -29,6 +29,37 @@ public class NetConnectUtil { private NetConnectUtil() { } + /** + * Base listener that forwards the four draft-specific callbacks to the given + * {@link ILobbyView}. Host-side and client-side listeners share the same draft + * forwarding logic; subclasses only need to override the non-draft methods. + */ + private static abstract class DraftForwardingLobbyListener implements ILobbyListener { + private final ILobbyView view; + + DraftForwardingLobbyListener(final ILobbyView view) { + this.view = view; + } + + @Override + public void draftPackArrived(int seatIndex, java.util.List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { + view.onDraftPackArrived(seatIndex, pack, packNumber, pickNumber, timerDurationSeconds); + } + @Override + public void draftSeatPicked(int seatIndex, int[] seatQueueDepths) { + view.onDraftSeatPicked(seatIndex, seatQueueDepths); + } + @Override + public void draftAutoPicked(int seatIndex, forge.item.PaperCard card, int packNumber, int pickInPack) { + view.onDraftAutoPicked(seatIndex, card, packNumber, pickInPack); + } + @Override + public void receiveEventPool(String eventId, forge.deck.Deck pool) { + view.onReceiveEventPool(eventId, pool); + } + } + /** * Prompt for the server address to join. Returns null if cancelled, or the address string. */ @@ -75,7 +106,7 @@ public void update(final boolean fullUpdate) { server.updateLobbyState(); }); - server.setLobbyListener(new ILobbyListener() { + server.setLobbyListener(new DraftForwardingLobbyListener(view) { @Override public void update(final GameLobbyData state, final int slot) { // NO-OP, lobby connected directly @@ -181,7 +212,7 @@ public static ChatMessage join(final String url, final IOnlineLobby onlineLobby, final ClientGameLobby lobby = new ClientGameLobby(); final ILobbyView view = onlineLobby.setLobby(lobby); lobby.setListener(view); - client.addLobbyListener(new ILobbyListener() { + client.addLobbyListener(new DraftForwardingLobbyListener(view) { @Override public void message(final String source, final String message, final ChatMessage.MessageType type) { chatInterface.addMessage(new ChatMessage(source, message, type)); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkEvent.java new file mode 100644 index 00000000000..dc07c7eee10 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkEvent.java @@ -0,0 +1,237 @@ +package forge.gamemodes.net; + +import forge.deck.Deck; +import forge.deck.DeckProxy; +import forge.gamemodes.limited.BoosterDraft; +import forge.gamemodes.limited.LimitedPoolType; +import forge.gamemodes.limited.SealedCardPoolGenerator; +import forge.model.FModel; +import forge.util.Localizer; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Model and helpers for a network limited event (draft or sealed). + *

+ * The instance side is the server-side mutable event — in-memory only during the event + * session, discarded after pool distribution. The wire-safe representation is + * {@link NetworkEventView}. Each participant's pool is persisted as a Deck with event + * metadata in tags. + *

+ * The static side is a home for cross-platform event helpers — tag I/O + * ({@link #setEventTags}, {@link #findEventTags}), display formatting + * ({@link #poolNameFor}, {@link #getEventDisplayLabel}, {@link #computeEventPanelText}), + * and the {@link EventChoice} / {@link EventPanelText} data records — so desktop and + * mobile UIs share identical behaviour without re-deriving the logic per platform. + */ +public final class NetworkEvent { + private final String eventId; + private final EventFormat format; + private EventPhase phase; + private final List participants; + private final LocalDateTime createdAt; + private int pickTimerSeconds; + private int disconnectGraceSeconds; + private String productDescription; + private LimitedPoolType poolType; + private int numRounds = 3; + private SealedCardPoolGenerator sealedGenerator; + private BoosterDraft draft; + + public NetworkEvent(EventFormat format) { + this.eventId = UUID.randomUUID().toString().substring(0, 8); + this.format = format; + this.phase = EventPhase.LOBBY_GATHER; + this.participants = new ArrayList<>(); + this.createdAt = LocalDateTime.now(); + this.pickTimerSeconds = 60; + this.disconnectGraceSeconds = 120; + this.productDescription = ""; + this.poolType = LimitedPoolType.Full; + } + + public String getEventId() { return eventId; } + public EventFormat getFormat() { return format; } + public EventPhase getPhase() { return phase; } + public void setPhase(EventPhase phase) { this.phase = phase; } + public List getParticipants() { return participants; } + public int getPickTimerSeconds() { return pickTimerSeconds; } + public void setPickTimerSeconds(int seconds) { this.pickTimerSeconds = seconds; } + public int getDisconnectGraceSeconds() { return disconnectGraceSeconds; } + public void setDisconnectGraceSeconds(int seconds) { this.disconnectGraceSeconds = seconds; } + public String getProductDescription() { return productDescription; } + public void setProductDescription(String desc) { this.productDescription = desc; } + public LimitedPoolType getPoolType() { return poolType; } + public void setPoolType(LimitedPoolType poolType) { this.poolType = poolType; } + public SealedCardPoolGenerator getSealedGenerator() { return sealedGenerator; } + public void setSealedGenerator(SealedCardPoolGenerator gen) { this.sealedGenerator = gen; } + public BoosterDraft getDraft() { return draft; } + public void setDraft(BoosterDraft draft) { this.draft = draft; } + public int getNumRounds() { return numRounds; } + public void setNumRounds(int numRounds) { this.numRounds = numRounds; } + + public void addParticipant(EventParticipant participant) { + participants.add(participant); + } + + private static final DateTimeFormatter EVENT_DATE_TAG = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + public static void setEventTags(Deck deck, NetworkEvent event) { + deck.getTags().add("eventId:" + event.getEventId()); + deck.getTags().add("eventFormat:" + event.getFormat().name()); + deck.getTags().add("eventProduct:" + event.getProductDescription()); + deck.getTags().add("eventDate:" + event.createdAt.format(EVENT_DATE_TAG)); + } + + private static final DateTimeFormatter POOL_NAME_DATE = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** Conventional pool name: "Format - Product - YYYY-MM-DD". */ + public static String poolNameFor(NetworkEvent event) { + String formatLabel = event.getFormat() == EventFormat.BOOSTER_DRAFT ? "Draft" : "Sealed"; + String product = productLabelFor(event.getProductDescription()); + return formatLabel + + " - " + product + + " - " + event.createdAt.format(POOL_NAME_DATE); + } + + /** Strip the "PoolType: " prefix ("Full: Innistrad" -> "Innistrad"), drop any trailing + * parenthetical (e.g., set-combo codes shown in the config panel), and remove + * filesystem-illegal chars — yields a concise label suitable for a deck filename. */ + private static String productLabelFor(String description) { + if (description == null || description.isEmpty()) return ""; + int sep = description.indexOf(": "); + String label = sep >= 0 ? description.substring(sep + 2) : description; + label = label.replaceAll("\\s*\\([^)]*\\)\\s*$", ""); + return label.replaceAll("[\\\\/:*?\"<>|]", "").trim(); + } + + public NetworkEventView toView() { + return new NetworkEventView(eventId, format, phase, + participants, pickTimerSeconds, productDescription, numRounds); + } + + /** An event id paired with its display label, e.g., for dialog-driven event selection. */ + public record EventChoice(String id, String label) { + @Override public String toString() { return label; } + } + + /** Pre-computed display strings for the event-details panel — platform-agnostic. */ + public record EventPanelText( + String formatText, + String productText, + String timerText, + String dateText, + String statusText) { } + + /** Returns {eventFormat, eventProduct, eventDate} for the deck tagged with eventId, or null. */ + public static String[] findEventTags(String eventId) { + for (Deck d : FModel.getDecks().getNetworkEventDecks()) { + if (eventId.equals(DeckProxy.getEventTag(d, "eventId"))) { + return new String[] { + DeckProxy.getEventTag(d, "eventFormat"), + DeckProxy.getEventTag(d, "eventProduct"), + DeckProxy.getEventTag(d, "eventDate"), + }; + } + } + return null; + } + + /** Short display label for a past event id, e.g., "Draft — Innistrad — (2026-04-20 10:15)". */ + public static String getEventDisplayLabel(String eventId) { + String[] tags = findEventTags(eventId); + if (tags == null) return eventId; + String displayFormat = EventFormat.BOOSTER_DRAFT.name().equals(tags[0]) ? "Draft" : "Sealed"; + return displayFormat + " — " + (tags[1] == null ? "" : tags[1]) + + " — (" + (tags[2] == null ? "" : tags[2]) + ")"; + } + + /** + * Compute the five display strings for the event-details panel, given state + lobby snapshot. + * Platform-agnostic: both desktop and mobile views can call this and get identical text. + * + * @param isHost whether this client owns the lobby + * @param activeEventId id of the loaded past event, or null if none loaded + * @param currentEvent host's in-flight event (null on client and when no event is being set up) + * @param lastEventView last-received wire snapshot (null before any broadcast arrives) + */ + public static EventPanelText computeEventPanelText( + boolean isHost, String activeEventId, + NetworkEvent currentEvent, NetworkEventView lastEventView) { + if (activeEventId != null) { + return textForLoadedEvent(activeEventId); + } + boolean inFlight = isHost ? currentEvent != null : lastEventView != null; + if (inFlight) { + return textForInFlightEvent(isHost, currentEvent, lastEventView); + } + return emptyEventText(); + } + + /** Text for a past event loaded from disk, read off the deck tags. */ + private static EventPanelText textForLoadedEvent(String eventId) { + Localizer localizer = Localizer.getInstance(); + String formatText = "—"; + String productText = "—"; + String dateText = "—"; + String[] tags = findEventTags(eventId); + if (tags != null) { + if (tags[0] != null) { + formatText = EventFormat.BOOSTER_DRAFT.name().equals(tags[0]) + ? localizer.getMessage("lblNetworkModeDraft") + : localizer.getMessage("lblNetworkModeSealed"); + } + if (tags[1] != null && !tags[1].isEmpty()) productText = tags[1]; + if (tags[2] != null && !tags[2].isEmpty()) dateText = tags[2]; + } + return new EventPanelText(formatText, productText, "—", dateText, ""); + } + + /** Text for an event currently being configured/drafted, read off the event model. */ + private static EventPanelText textForInFlightEvent(boolean isHost, + NetworkEvent currentEvent, NetworkEventView lastEventView) { + Localizer localizer = Localizer.getInstance(); + EventFormat evFormat; + int timerSec; + String desc; + LimitedPoolType pool = null; + if (isHost) { + evFormat = currentEvent.getFormat(); + timerSec = currentEvent.getPickTimerSeconds(); + desc = currentEvent.getProductDescription(); + pool = currentEvent.getPoolType(); + } else { + evFormat = lastEventView.getFormat(); + timerSec = lastEventView.getPickTimerSeconds(); + desc = lastEventView.getProductDescription(); + } + String formatText = (evFormat == EventFormat.BOOSTER_DRAFT) + ? localizer.getMessage("lblNetworkModeDraft") + : localizer.getMessage("lblNetworkModeSealed"); + String productText = "—"; + if (desc != null && !desc.isEmpty()) { + productText = desc; + } else if (pool != null) { + productText = pool.toString(); + } + String timerText = (evFormat == EventFormat.BOOSTER_DRAFT) + ? (timerSec > 0 ? timerSec + "s" : "—") + : localizer.getMessage("lblNetworkPickTimerNotApplicable"); + String dateText = localizer.getMessage(evFormat == EventFormat.SEALED + ? "lblNetworkNewEventNoPools" : "lblNetworkNewEventNotDrafted"); + return new EventPanelText(formatText, productText, timerText, dateText, ""); + } + + /** Placeholder text when no event is configured yet — caller typically shows a "waiting" message. */ + private static EventPanelText emptyEventText() { + Localizer localizer = Localizer.getInstance(); + return new EventPanelText("—", "—", "—", "—", + localizer.getMessage("lblNetworkWaitingForHost")); + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkEventView.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkEventView.java new file mode 100644 index 00000000000..b3b760d2f5c --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkEventView.java @@ -0,0 +1,41 @@ +package forge.gamemodes.net; + +import java.io.Serializable; +import java.util.List; + +/** + * Immutable, serializable snapshot of a {@link NetworkEvent} for transmission to clients. + * Contains event metadata (format, phase, participants, timer) but not server-side + * state like the SealedCardPoolGenerator or BoosterDraftHost reference. + */ +public final class NetworkEventView implements Serializable { + private static final long serialVersionUID = 1L; + + private final String eventId; + private final EventFormat format; + private final EventPhase phase; + private final List participants; + private final int pickTimerSeconds; + private final String productDescription; + private final int numRounds; + + public NetworkEventView(String eventId, EventFormat format, EventPhase phase, + List participants, int pickTimerSeconds, + String productDescription, int numRounds) { + this.eventId = eventId; + this.format = format; + this.phase = phase; + this.participants = List.copyOf(participants); + this.pickTimerSeconds = pickTimerSeconds; + this.productDescription = productDescription; + this.numRounds = numRounds; + } + + public String getEventId() { return eventId; } + public EventFormat getFormat() { return format; } + public EventPhase getPhase() { return phase; } + public List getParticipants() { return participants; } + public int getPickTimerSeconds() { return pickTimerSeconds; } + public String getProductDescription() { return productDescription; } + public int getNumRounds() { return numRounds; } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java b/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java index a6f0d4c0ef4..665e3c04676 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java @@ -190,6 +190,29 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw for (final ILobbyListener listener : lobbyListeners) { listener.update(event.getState(), event.getSlot()); } + } else if (msg instanceof ReceiveEventPoolEvent event) { + for (final ILobbyListener listener : lobbyListeners) { + listener.receiveEventPool(event.getEventId(), event.getPool()); + } + return; + } else if (msg instanceof DraftAutoPickedEvent event) { + for (final ILobbyListener listener : lobbyListeners) { + listener.draftAutoPicked(event.getSeatIndex(), event.getCard(), + event.getPackNumber(), event.getPickInPack()); + } + return; + } else if (msg instanceof DraftPackArrivedEvent event) { + for (final ILobbyListener listener : lobbyListeners) { + listener.draftPackArrived(event.getSeatIndex(), event.getPack(), + event.getPackNumber(), event.getPickNumber(), + event.getTimerDurationSeconds()); + } + return; + } else if (msg instanceof DraftSeatPickedEvent event) { + for (final ILobbyListener listener : lobbyListeners) { + listener.draftSeatPicked(event.getSeatIndex(), event.getSeatQueueDepths()); + } + return; } super.channelRead(ctx, msg); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/draft/BoosterDraftHost.java b/forge-gui/src/main/java/forge/gamemodes/net/draft/BoosterDraftHost.java new file mode 100644 index 00000000000..3a9bff51209 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/draft/BoosterDraftHost.java @@ -0,0 +1,544 @@ +package forge.gamemodes.net.draft; + +import forge.deck.Deck; +import forge.deck.DeckSection; +import forge.gamemodes.limited.BoosterDraft; +import forge.gamemodes.net.EventParticipant; +import forge.gamemodes.net.EventPhase; +import forge.gamemodes.net.NetworkEvent; +import forge.gamemodes.limited.DraftPack; +import forge.gamemodes.limited.LimitedPlayer; +import forge.gamemodes.limited.LimitedPlayerAI; +import forge.gamemodes.net.event.DraftAutoPickedEvent; +import forge.gamemodes.net.event.DraftPackArrivedEvent; +import forge.gamemodes.net.event.DraftSeatPickedEvent; +import forge.gamemodes.net.event.MessageEvent; +import forge.gamemodes.net.event.ReceiveEventPoolEvent; +import forge.gamemodes.net.server.FServerManager; +import forge.item.PaperCard; +import forge.util.IHasForgeLog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Server-side adapter that wraps {@link BoosterDraft} for network play. + * + *

Async model: each seat has its own pack queue. When a seat picks, the + * picked-from pack is passed to the next seat in the pass direction immediately, + * regardless of what other seats are doing. A fast picker may bank up multiple + * packs while waiting for slower seats. Each human pick has its own timer, + * reset whenever a new pack reaches the head of the queue. + * + *

Mutable state is guarded by {@code synchronized(this)}, but all network + * dispatch is deferred to a list and run outside the monitor — otherwise a slow + * client's {@code channel.writeAndFlush().sync()} would block the entire pod. + */ +public final class BoosterDraftHost implements IHasForgeLog { + + /** + * Per-seat connection state. A disconnected seat enters {@code IN_GRACE} for + * {@link NetworkEvent#getDisconnectGraceSeconds()}; if no reconnect happens in + * that window it transitions to {@code POST_GRACE_AUTO} where all future packs + * auto-pick first-card on arrival. Reconnect at any point returns the seat to + * {@code LIVE}. A grace value of zero skips IN_GRACE entirely. + */ + private enum SeatConnectionState { LIVE, IN_GRACE, POST_GRACE_AUTO } + + private final BoosterDraft draft; + private final NetworkEvent event; + private final List participants; + private int currentPackNumber; // 1-based round number — used to decide pass direction + private int initialPackSize; // pack size at start of current round, for pick-number display + private volatile boolean finished; + + /** Whether a human seat currently has a pack notification in flight (waiting for pick). */ + private final boolean[] inFlight; + + /** Total picks each seat has committed so far (1-based pick number after each call). */ + private final int[] picksMadePerSeat; + + /** Per-seat connection state — initialized LIVE; transitioned by disconnect/reconnect. */ + private final SeatConnectionState[] seatState; + + /** Per-seat pick timers. Started when a pack is sent, cancelled on pick. */ + private final Map> seatTimers = new HashMap<>(); + + /** Per-seat grace timers — scheduled on disconnect, cancelled on reconnect. */ + private final Map> graceTimers = new HashMap<>(); + + private final ScheduledExecutorService timerExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "DraftPickTimer"); + t.setDaemon(true); + return t; + }); + + public BoosterDraftHost(BoosterDraft draft, NetworkEvent event) { + this.draft = draft; + this.event = event; + // Snapshot participants so a lobby-side repopulate after draft start + // can't corrupt the host's running pod. + this.participants = new ArrayList<>(event.getParticipants()); + this.currentPackNumber = draft.getRound(); + this.finished = false; + int podSize = draft.getAllPlayers().size(); + this.inFlight = new boolean[podSize]; + this.picksMadePerSeat = new int[podSize]; + this.seatState = new SeatConnectionState[podSize]; + Arrays.fill(this.seatState, SeatConnectionState.LIVE); + } + + /** + * Start the draft: set phase and distribute initial packs. + * Called once after the BoosterDraft has been initialized. + */ + public void start() { + List dispatches; + synchronized (this) { + long humans = participants.stream().filter(EventParticipant::isHuman).count(); + netLog.info("Draft started — {} humans, {} seats total, timer={}s, product={}", + humans, participants.size(), event.getPickTimerSeconds(), event.getProductDescription()); + event.setPhase(EventPhase.DRAFTING); + captureInitialPackSize(); + dispatches = new ArrayList<>(); + advanceDraft(dispatches); + } + run(dispatches); + } + + /** + * Stop the draft and release timer resources. Safe to call multiple times. + * Does not distribute pools — call before the draft has legitimately finished + * (e.g. host cleared the event mid-draft, lobby shutting down). + */ + public synchronized void shutdown() { + finished = true; + cancelAllSeatTimers(); + cancelAllGraceTimers(); + timerExecutor.shutdown(); + } + + /** + * Handle an incoming pick from a human client. + * + * @param seatIndex the seat that made the pick + * @param card the chosen card + */ + public void handlePick(int seatIndex, PaperCard card) { + List dispatches; + synchronized (this) { + if (finished) return; + List players = draft.getAllPlayers(); + if (seatIndex < 0 || seatIndex >= players.size()) { + netLog.warn("Invalid seat index: {}", seatIndex); + return; + } + LimitedPlayer player = players.get(seatIndex); + DraftPack headPack = player.nextChoice(); + if (headPack == null || !headPack.contains(card)) { + netLog.warn("Seat {} picked a card not in the current pack", seatIndex); + return; + } + + dispatches = new ArrayList<>(); + applyPickAndPass(player, seatIndex, card); + cancelSeatTimer(seatIndex); + inFlight[seatIndex] = false; + + netLog.info("Seat {} picked from pack {}", seatIndex, currentPackNumber); + addBroadcastSeatPicked(dispatches, seatIndex); + advanceDraft(dispatches); + } + run(dispatches); + } + + /** + * Apply a pick and, if the card's effect passes the pack, dequeue it from + * the picker's queue and route it to the next seat in direction. Conspiracy + * cards such as Agent of Acquisitions cause {@code draftCard} to return + * {@code false}, meaning the picker keeps the pack for another pick. + */ + private void applyPickAndPass(LimitedPlayer player, int seatIndex, PaperCard card) { + Boolean passPack = player.draftCard(card, DeckSection.Sideboard); + picksMadePerSeat[seatIndex]++; + if (!Boolean.FALSE.equals(passPack)) { + DraftPack passed = player.passPack(); + if (passed != null && !passed.isEmpty()) { + passToNext(seatIndex, passed); + } + } + } + + /** + * Core distribution loop: advance rounds, let AI pick, notify humans of + * packs at the head of their queue. Collects network dispatches into + * {@code dispatches} to be run outside the monitor. + */ + private void advanceDraft(List dispatches) { + while (!finished) { + // Round advancement — all queues drained means the round is over + if (draft.isRoundOver()) { + if (!draft.startRound()) { + addFinishDraft(dispatches); + return; + } + currentPackNumber = draft.getRound(); + captureInitialPackSize(); + } + + // Let any one AI with a pack pick, then restart so we re-check state + List players = draft.getAllPlayers(); + boolean aiProgressed = false; + for (int i = 0; i < players.size(); i++) { + LimitedPlayer p = players.get(i); + if (!(p instanceof LimitedPlayerAI ai)) continue; + DraftPack head = p.nextChoice(); + if (head == null || head.isEmpty()) continue; + + if (p.shouldSkipThisPick()) { + // Skip without picking — pass the pack along + DraftPack skipPass = p.passPack(); + if (skipPass != null && !skipPass.isEmpty()) passToNext(i, skipPass); + aiProgressed = true; + break; + } + + PaperCard choice = ai.chooseCard(); + if (choice == null) continue; + applyPickAndPass(ai, i, choice); + addBroadcastSeatPicked(dispatches, i); + aiProgressed = true; + break; + } + if (aiProgressed) continue; + + // Drain any seat that has timed out its grace window — same pattern + // as the AI loop above: one pick per pass, then restart. + boolean autoPicked = false; + for (int i = 0; i < players.size(); i++) { + LimitedPlayer p = players.get(i); + if (p instanceof LimitedPlayerAI) continue; + if (seatState[i] != SeatConnectionState.POST_GRACE_AUTO) continue; + DraftPack head = p.nextChoice(); + if (head == null || head.isEmpty()) continue; + + PaperCard autoPick = head.get(0); + netLog.info("Seat {} disconnected past grace — auto-picking {}", i, autoPick.getName()); + applyPickAndPass(p, i, autoPick); + addBroadcastSeatPicked(dispatches, i); + autoPicked = true; + break; + } + if (autoPicked) continue; + + // No AI/auto work left — notify any live humans with a fresh pack. + // Seats in IN_GRACE hold their packs silently until they reconnect + // or grace expires; POST_GRACE_AUTO is already handled above. + for (int i = 0; i < players.size(); i++) { + LimitedPlayer p = players.get(i); + if (p instanceof LimitedPlayerAI) continue; + if (seatState[i] != SeatConnectionState.LIVE) continue; + DraftPack head = p.nextChoice(); + if (head == null || head.isEmpty()) continue; + if (inFlight[i]) continue; + + addSendPackToHuman(dispatches, i, head); + inFlight[i] = true; + startSeatTimer(i); + } + return; + } + } + + /** + * Pass a non-empty pack from {@code fromSeat} to the next seat in the current + * pass direction (odd packs go right, even packs go left — MTG convention). + */ + private void passToNext(int fromSeat, DraftPack pack) { + int podSize = draft.getAllPlayers().size(); + int dir = (currentPackNumber % 2 == 1) ? 1 : -1; + int nextSeat = ((fromSeat + dir) % podSize + podSize) % podSize; + draft.getAllPlayers().get(nextSeat).receiveOpenedPack(pack); + } + + private void captureInitialPackSize() { + for (LimitedPlayer pl : draft.getAllPlayers()) { + DraftPack pack = pl.nextChoice(); + if (pack != null && !pack.isEmpty()) { + initialPackSize = pack.size(); + return; + } + } + } + + /** Pick number (0-based) within the current pack, derived from cards remaining. */ + private int pickNumberFor(DraftPack pack) { + return pack == null ? 0 : Math.max(0, initialPackSize - pack.size()); + } + + private void addSendPackToHuman(List dispatches, int seatIndex, DraftPack pack) { + EventParticipant participant = EventParticipant.findBySeat(participants, seatIndex); + if (participant == null || participant.isAI()) return; + + List packCards = new ArrayList<>(pack); + int packNum = currentPackNumber; + int pickNum = pickNumberFor(pack); + int timerSecs = event.getPickTimerSeconds(); + int slot = participant.getLobbySlotIndex(); + + dispatches.add(() -> FServerManager.getInstance().sendToSlot(slot, + new DraftPackArrivedEvent(seatIndex, packCards, packNum, pickNum, timerSecs))); + } + + private void addBroadcastSeatPicked(List dispatches, int seatIndex) { + int[] queueDepths = computeQueueDepths(); + dispatches.add(() -> FServerManager.getInstance().broadcast( + new DraftSeatPickedEvent(seatIndex, queueDepths))); + } + + private int computePickInPack(int seatPickCount) { + int size = Math.max(1, initialPackSize); + return ((seatPickCount - 1) % size) + 1; + } + + private int[] computeQueueDepths() { + List players = draft.getAllPlayers(); + int[] depths = new int[players.size()]; + for (int i = 0; i < players.size(); i++) { + depths[i] = players.get(i).getPackQueueSize(); + } + return depths; + } + + /** + * Build pools and queue sends to each human participant. Called from inside + * the monitor; the actual network dispatch happens after release. + */ + private void addFinishDraft(List dispatches) { + finished = true; + cancelAllSeatTimers(); + cancelAllGraceTimers(); + timerExecutor.shutdown(); + draft.postDraftActions(); + netLog.info("Draft complete — distributing pools"); + + List players = draft.getAllPlayers(); + String eventId = event.getEventId(); + + for (int i = 0; i < players.size(); i++) { + LimitedPlayer player = players.get(i); + if (player instanceof LimitedPlayerAI) continue; + + EventParticipant participant = EventParticipant.findBySeat(participants, i); + if (participant == null) continue; + + Deck pool = new Deck(player.getDeck(), NetworkEvent.poolNameFor(event)); + NetworkEvent.setEventTags(pool, event); + int slot = participant.getLobbySlotIndex(); + dispatches.add(() -> FServerManager.getInstance().sendToSlot(slot, + new ReceiveEventPoolEvent(eventId, pool))); + } + } + + private void startSeatTimer(int seatIndex) { + cancelSeatTimer(seatIndex); + int seconds = event.getPickTimerSeconds(); + if (seconds <= 0) return; + ScheduledFuture f = timerExecutor.schedule( + () -> onSeatTimerExpired(seatIndex), seconds, TimeUnit.SECONDS); + seatTimers.put(seatIndex, f); + } + + private void cancelSeatTimer(int seatIndex) { + ScheduledFuture f = seatTimers.remove(seatIndex); + if (f != null) f.cancel(false); + } + + private void cancelAllSeatTimers() { + for (Integer seatIndex : new ArrayList<>(seatTimers.keySet())) { + cancelSeatTimer(seatIndex); + } + } + + // --- Disconnect / reconnect handling --- + + /** + * Notify the host that a drafting client's channel has been lost. The seat's + * pick timer is cancelled and a grace window opens (duration from + * {@link NetworkEvent#getDisconnectGraceSeconds()}); if no reconnect arrives + * the seat switches to permanent auto-pick mode until the draft ends or the + * player returns. + * + *

No-op for AI seats (not tied to channels) and for seats not currently + * {@code LIVE} (idempotent against repeated disconnect signals). + */ + public void onSeatDisconnected(int seatIndex) { + List dispatches; + synchronized (this) { + if (finished || seatIndex < 0 || seatIndex >= seatState.length) return; + if (seatState[seatIndex] != SeatConnectionState.LIVE) return; + if (isAiSeat(seatIndex)) return; + + cancelSeatTimer(seatIndex); + // Clear inFlight so the live-pack-distribution loop in advanceDraft + // will re-send the current pack if the player reconnects before grace. + inFlight[seatIndex] = false; + + int graceSeconds = event.getDisconnectGraceSeconds(); + dispatches = new ArrayList<>(); + addBroadcastDisconnect(dispatches, seatIndex); + if (graceSeconds > 0) { + seatState[seatIndex] = SeatConnectionState.IN_GRACE; + graceTimers.put(seatIndex, timerExecutor.schedule( + () -> onGraceExpired(seatIndex), graceSeconds, TimeUnit.SECONDS)); + netLog.info("Seat {} disconnected — {}s grace started", seatIndex, graceSeconds); + } else { + // Zero-grace config — skip IN_GRACE and start auto-picking immediately. + seatState[seatIndex] = SeatConnectionState.POST_GRACE_AUTO; + netLog.info("Seat {} disconnected — grace disabled, auto-picking immediately", seatIndex); + advanceDraft(dispatches); + } + } + run(dispatches); + } + + /** + * Notify the host that a previously-disconnected seat has reconnected. If a + * pack is currently at the head of the seat's queue it's re-sent to the + * client and the pick timer restarts; otherwise the next pack to arrive will + * be sent via the normal {@code advanceDraft} path. + */ + public void onSeatReconnected(int seatIndex) { + List dispatches; + synchronized (this) { + if (finished || seatIndex < 0 || seatIndex >= seatState.length) return; + if (seatState[seatIndex] == SeatConnectionState.LIVE) return; + + cancelGraceTimer(seatIndex); + seatState[seatIndex] = SeatConnectionState.LIVE; + + dispatches = new ArrayList<>(); + LimitedPlayer player = draft.getAllPlayers().get(seatIndex); + DraftPack head = player.nextChoice(); + if (head != null && !head.isEmpty()) { + addSendPackToHuman(dispatches, seatIndex, head); + inFlight[seatIndex] = true; + startSeatTimer(seatIndex); + } + addBroadcastReconnect(dispatches, seatIndex); + netLog.info("Seat {} reconnected", seatIndex); + } + run(dispatches); + } + + /** Grace timer callback — transition to POST_GRACE_AUTO and drain held/queued packs. */ + private void onGraceExpired(int seatIndex) { + List dispatches; + synchronized (this) { + if (finished || seatState[seatIndex] != SeatConnectionState.IN_GRACE) return; + seatState[seatIndex] = SeatConnectionState.POST_GRACE_AUTO; + graceTimers.remove(seatIndex); + + netLog.info("Seat {} grace expired — switching to auto-pick", seatIndex); + dispatches = new ArrayList<>(); + addBroadcastGraceExpired(dispatches, seatIndex); + advanceDraft(dispatches); + } + run(dispatches); + } + + private boolean isAiSeat(int seatIndex) { + EventParticipant p = EventParticipant.findBySeat(participants, seatIndex); + return p == null || p.isAI(); + } + + private void cancelGraceTimer(int seatIndex) { + ScheduledFuture f = graceTimers.remove(seatIndex); + if (f != null) f.cancel(false); + } + + private void cancelAllGraceTimers() { + for (ScheduledFuture f : graceTimers.values()) { + if (f != null) f.cancel(false); + } + graceTimers.clear(); + } + + private void addBroadcastDisconnect(List dispatches, int seatIndex) { + EventParticipant participant = EventParticipant.findBySeat(participants, seatIndex); + if (participant == null) return; + String name = participant.getName(); + int graceSeconds = event.getDisconnectGraceSeconds(); + String msg = graceSeconds > 0 + ? String.format("%s disconnected from draft — %ds to reconnect before auto-picking starts.", + name, graceSeconds) + : String.format("%s disconnected from draft — auto-picking remaining packs.", name); + dispatches.add(() -> FServerManager.getInstance().broadcast(new MessageEvent(msg))); + } + + private void addBroadcastReconnect(List dispatches, int seatIndex) { + EventParticipant participant = EventParticipant.findBySeat(participants, seatIndex); + if (participant == null) return; + String name = participant.getName(); + dispatches.add(() -> FServerManager.getInstance().broadcast(new MessageEvent( + String.format("%s reconnected — picking live again.", name)))); + } + + private void addBroadcastGraceExpired(List dispatches, int seatIndex) { + EventParticipant participant = EventParticipant.findBySeat(participants, seatIndex); + if (participant == null) return; + String name = participant.getName(); + dispatches.add(() -> FServerManager.getInstance().broadcast(new MessageEvent( + String.format("%s grace period expired — auto-picking remaining packs.", name)))); + } + + /** Auto-pick the first card for a single seat that timed out. */ + private void onSeatTimerExpired(int seatIndex) { + List dispatches; + synchronized (this) { + // Defensive: a disconnected seat's pick timer is cancelled on disconnect, + // but guard against the runnable firing between cancel scheduling and the + // monitor being acquired. + if (finished || !inFlight[seatIndex] || seatState[seatIndex] != SeatConnectionState.LIVE) return; + + List players = draft.getAllPlayers(); + LimitedPlayer player = players.get(seatIndex); + DraftPack pack = player.nextChoice(); + if (pack == null || pack.isEmpty()) return; + + PaperCard autoPick = pack.get(0); + netLog.info("Pick timer expired for seat {} — auto-picking {}", seatIndex, autoPick.getName()); + + dispatches = new ArrayList<>(); + applyPickAndPass(player, seatIndex, autoPick); + inFlight[seatIndex] = false; + + // Auto-pick first so client's pending-self cache is set before the echo flushes it. + addNotifyAutoPick(dispatches, seatIndex, autoPick); + addBroadcastSeatPicked(dispatches, seatIndex); + advanceDraft(dispatches); + } + run(dispatches); + } + + private void addNotifyAutoPick(List dispatches, int seatIndex, PaperCard card) { + EventParticipant participant = EventParticipant.findBySeat(participants, seatIndex); + if (participant == null || participant.isAI()) return; + int slot = participant.getLobbySlotIndex(); + int packNumber = currentPackNumber; + int pickInPack = computePickInPack(picksMadePerSeat[seatIndex]); + dispatches.add(() -> FServerManager.getInstance().sendToSlot(slot, + new DraftAutoPickedEvent(seatIndex, card, packNumber, pickInPack))); + } + + private static void run(List dispatches) { + for (Runnable r : dispatches) r.run(); + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/event/DraftAutoPickedEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftAutoPickedEvent.java new file mode 100644 index 00000000000..b054933312f --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftAutoPickedEvent.java @@ -0,0 +1,27 @@ +package forge.gamemodes.net.event; + +import forge.gamemodes.net.server.RemoteClient; +import forge.item.PaperCard; + +public final class DraftAutoPickedEvent implements NetEvent { + private static final long serialVersionUID = 1L; + private final int seatIndex; + private final PaperCard card; + private final int packNumber; + private final int pickInPack; + + public DraftAutoPickedEvent(int seatIndex, PaperCard card, int packNumber, int pickInPack) { + this.seatIndex = seatIndex; + this.card = card; + this.packNumber = packNumber; + this.pickInPack = pickInPack; + } + + public int getSeatIndex() { return seatIndex; } + public PaperCard getCard() { return card; } + public int getPackNumber() { return packNumber; } + public int getPickInPack() { return pickInPack; } + + @Override + public void updateForClient(RemoteClient client) { } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPackArrivedEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPackArrivedEvent.java new file mode 100644 index 00000000000..c305ea67a4f --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPackArrivedEvent.java @@ -0,0 +1,36 @@ +package forge.gamemodes.net.event; + +import forge.gamemodes.net.server.RemoteClient; +import forge.item.PaperCard; +import java.util.List; + +/** + * Server -> specific client: a draft pack has arrived for picking. + * Includes the timer duration (fire-and-forget, client runs local countdown). + */ +public final class DraftPackArrivedEvent implements NetEvent { + private static final long serialVersionUID = 1L; + private final int seatIndex; + private final List pack; + private final int packNumber; + private final int pickNumber; + private final int timerDurationSeconds; + + public DraftPackArrivedEvent(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { + this.seatIndex = seatIndex; + this.pack = List.copyOf(pack); + this.packNumber = packNumber; + this.pickNumber = pickNumber; + this.timerDurationSeconds = timerDurationSeconds; + } + + public int getSeatIndex() { return seatIndex; } + public List getPack() { return pack; } + public int getPackNumber() { return packNumber; } + public int getPickNumber() { return pickNumber; } + public int getTimerDurationSeconds() { return timerDurationSeconds; } + + @Override + public void updateForClient(RemoteClient client) { } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPickEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPickEvent.java new file mode 100644 index 00000000000..5c66e9b0d06 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftPickEvent.java @@ -0,0 +1,21 @@ +package forge.gamemodes.net.event; + +import forge.gamemodes.net.server.RemoteClient; +import forge.item.PaperCard; + +public final class DraftPickEvent implements NetEvent { + private static final long serialVersionUID = 1L; + private final int seatIndex; + private final PaperCard card; + + public DraftPickEvent(int seatIndex, PaperCard card) { + this.seatIndex = seatIndex; + this.card = card; + } + + public int getSeatIndex() { return seatIndex; } + public PaperCard getCard() { return card; } + + @Override + public void updateForClient(RemoteClient client) { } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/event/DraftSeatPickedEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftSeatPickedEvent.java new file mode 100644 index 00000000000..5b49d24f355 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/event/DraftSeatPickedEvent.java @@ -0,0 +1,24 @@ +package forge.gamemodes.net.event; + +import forge.gamemodes.net.server.RemoteClient; + +/** + * Server -> all clients: a seat has made their pick. + * No card name revealed. Includes per-seat queue depths for the picker window. + */ +public final class DraftSeatPickedEvent implements NetEvent { + private static final long serialVersionUID = 1L; + private final int seatIndex; + private final int[] seatQueueDepths; + + public DraftSeatPickedEvent(int seatIndex, int[] seatQueueDepths) { + this.seatIndex = seatIndex; + this.seatQueueDepths = seatQueueDepths.clone(); + } + + public int getSeatIndex() { return seatIndex; } + public int[] getSeatQueueDepths() { return seatQueueDepths; } + + @Override + public void updateForClient(RemoteClient client) { } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/event/ReceiveEventPoolEvent.java b/forge-gui/src/main/java/forge/gamemodes/net/event/ReceiveEventPoolEvent.java new file mode 100644 index 00000000000..7a846482dcb --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/event/ReceiveEventPoolEvent.java @@ -0,0 +1,23 @@ +package forge.gamemodes.net.event; + +import java.util.Objects; + +import forge.deck.Deck; +import forge.gamemodes.net.server.RemoteClient; + +public final class ReceiveEventPoolEvent implements NetEvent { + private static final long serialVersionUID = 1L; + private final String eventId; + private final Deck pool; + + public ReceiveEventPoolEvent(String eventId, Deck pool) { + this.eventId = Objects.requireNonNull(eventId, "eventId"); + this.pool = Objects.requireNonNull(pool, "pool"); + } + + public String getEventId() { return eventId; } + public Deck getPool() { return pool; } + + @Override + public void updateForClient(RemoteClient client) { } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index c0c48a065bb..2d8316c2485 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -11,7 +11,9 @@ import forge.gamemodes.net.ChatMessage; import forge.gamemodes.net.CompatibleObjectDecoder; import forge.gamemodes.net.CompatibleObjectEncoder; +import forge.gamemodes.net.EventPhase; import forge.gamemodes.net.NetworkLogConfig; +import forge.gamemodes.net.draft.BoosterDraftHost; import forge.util.IHasForgeLog; import forge.gamemodes.net.event.*; import forge.gui.GuiBase; @@ -99,6 +101,30 @@ RemoteClient getClient(final Channel ch) { return clients.get(ch); } + /** O(n) scan — pod size is capped at 8, so the map keyed by Channel stays the source of truth. */ + public RemoteClient getClientBySlotIndex(int slotIndex) { + for (RemoteClient client : clients.values()) { + if (client.getIndex() == slotIndex) { + return client; + } + } + return null; + } + + /** + * Send an event to the given slot. If the slot is a remote client, sends + * the NetEvent over the wire; otherwise dispatches it to the local lobby + * listener (the host's own path) via {@link #dispatchToLocalListener}. + */ + public void sendToSlot(int slotIndex, NetEvent remoteEvent) { + RemoteClient client = getClientBySlotIndex(slotIndex); + if (client != null) { + client.send(remoteEvent); + } else { + dispatchToLocalListener(remoteEvent); + } + } + IGameController getController(final int index) { return localLobby.getController(index); } @@ -258,12 +284,32 @@ public int getTotalSendErrors() { } public void broadcast(final NetEvent event) { - if (event instanceof MessageEvent msgEvent) { - lobbyListener.message(msgEvent.getSource(), msgEvent.getMessage(), msgEvent.getType()); - } + dispatchToLocalListener(event); broadcastTo(event, clients.values()); } + /** + * Dispatch a broadcast event to the host's local listener — the host does + * not receive its own broadcasts over the network, so we mirror them here. + * Kept in sync with {@code FGameClient.LobbyUpdateHandler}. + */ + private void dispatchToLocalListener(final NetEvent event) { + if (lobbyListener == null) return; + if (event instanceof MessageEvent e) { + lobbyListener.message(e.getSource(), e.getMessage(), e.getType()); + } else if (event instanceof ReceiveEventPoolEvent e) { + lobbyListener.receiveEventPool(e.getEventId(), e.getPool()); + } else if (event instanceof DraftAutoPickedEvent e) { + lobbyListener.draftAutoPicked(e.getSeatIndex(), e.getCard(), + e.getPackNumber(), e.getPickInPack()); + } else if (event instanceof DraftPackArrivedEvent e) { + lobbyListener.draftPackArrived(e.getSeatIndex(), e.getPack(), + e.getPackNumber(), e.getPickNumber(), e.getTimerDurationSeconds()); + } else if (event instanceof DraftSeatPickedEvent e) { + lobbyListener.draftSeatPicked(e.getSeatIndex(), e.getSeatQueueDepths()); + } + } + public String formatAfkTimeoutMessage() { final int minutes = FModel.getNetPreferences().getPrefInt(ForgeNetPreferences.FNetPref.NET_AFK_TIMEOUT); if (minutes <= 0) { @@ -969,7 +1015,21 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw // Resume and resync resumeAndResync(disconnected); - broadcast(new MessageEvent(String.format("%s has reconnected.", username))); + // Draft-side resync: re-send the current pack and clear grace state. + // The draft host broadcasts its own reconnect message, so suppress + // the generic one below to avoid double-firing. + final boolean draftInProgress = localLobby != null + && localLobby.getCurrentEvent() != null + && localLobby.getCurrentEvent().getPhase() == EventPhase.DRAFTING; + if (draftInProgress) { + final int seat = localLobby.findSeatForLobbySlot(disconnected.getIndex()); + final BoosterDraftHost host = localLobby.getDraftHost(); + if (seat >= 0 && host != null) { + host.onSeatReconnected(seat); + } + } else { + broadcast(new MessageEvent(String.format("%s has reconnected.", username))); + } netLog.info("[Reconnect] Player reconnected: {}", username); } else { // Normal login flow @@ -1003,6 +1063,11 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw } } else if (msg instanceof UpdateLobbyPlayerEvent event) { updateSlot(client.getIndex(), event); + } else if (msg instanceof DraftPickEvent pickEvent) { + if (localLobby != null) { + localLobby.handleDraftPick(pickEvent, client.getIndex()); + } + return; } // Note: MessageEvent is handled by MessageHandler, not here // to avoid duplicate display on host's chat @@ -1044,12 +1109,13 @@ public void channelInactive(final ChannelHandlerContext ctx) throws Exception { netLog.info("[Disconnect] Client disconnected: index={}, username={}", playerIndex, username); + final boolean draftInProgress = localLobby != null + && localLobby.getCurrentEvent() != null + && localLobby.getCurrentEvent().getPhase() == EventPhase.DRAFTING; + if (isMatchActive() && client.hasValidSlot()) { - // Game is active — enter reconnection mode - // Pause the RemoteClientGuiGame so sends become no-ops + // Match is active — pause, store for reconnect, run 5-minute reclaim timer. pauseRemoteClientGuiGame(playerIndex); - - // Store for reconnection lookup disconnectedClients.put(username, client); // Start periodic countdown timer (ticks every 30s) @@ -1074,6 +1140,17 @@ public void run() { String.format("%s disconnected. Waiting %s for reconnect...", username, formatTime(RECONNECT_TIMEOUT_SECONDS)))); lobbyListener.message(null, "(Host can use /skipreconnect to replace disconnected player with AI, or /skiptimeout to wait indefinitely.)", ChatMessage.MessageType.SYSTEM); netLog.info("[Disconnect] Player disconnected mid-game: {} (slot {}). Waiting for reconnect.", username, playerIndex); + } else if (draftInProgress && client.hasValidSlot()) { + // Draft is in progress — let the draft host own the grace window and + // chat announcements. Keep the client in disconnectedClients so the + // reconnect handshake can find them; no 5-minute slot-reclaim timer. + final int seat = localLobby.findSeatForLobbySlot(playerIndex); + final BoosterDraftHost host = localLobby.getDraftHost(); + if (seat >= 0 && host != null) { + host.onSeatDisconnected(seat); + } + disconnectedClients.put(username, client); + netLog.info("[Disconnect] Draft client disconnected: {} (slot {}). Grace started.", username, playerIndex); } else if (client.hasValidSlot()) { // Peer completed registration but match isn't active (or slot was freed earlier) localLobby.disconnectPlayer(playerIndex); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java index 411f611e33e..0ea20aad2b5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java @@ -1,14 +1,58 @@ package forge.gamemodes.net.server; +import forge.deck.CardPool; +import forge.deck.Deck; +import forge.deck.DeckSection; +import forge.gamemodes.limited.BoosterDraft; +import forge.gamemodes.limited.LimitedPoolType; +import forge.gamemodes.limited.SealedCardPoolGenerator; import forge.gamemodes.match.GameLobby; import forge.gamemodes.match.LobbySlot; import forge.gamemodes.match.LobbySlotType; +import forge.gamemodes.net.draft.BoosterDraftHost; +import forge.gamemodes.net.EventFormat; +import forge.gamemodes.net.EventParticipant; +import forge.gamemodes.net.EventPhase; +import forge.gamemodes.net.NetworkEvent; +import forge.gamemodes.net.event.DraftPickEvent; +import forge.gamemodes.net.event.ReceiveEventPoolEvent; import forge.gui.interfaces.IGuiGame; +import forge.util.IHasForgeLog; import org.apache.commons.lang3.StringUtils; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; -public final class ServerGameLobby extends GameLobby { +public final class ServerGameLobby extends GameLobby implements IHasForgeLog { + private static final int DRAFT_POD_SIZE = 8; + + /** Returned by {@link #startDraftEvent} with the info the UI needs for overlay/log setup. */ + public record DraftStartResult(String[] names, boolean[] aiFlags, int hostSeatIndex, int totalPacks) {} + + private BoosterDraftHost draftHost; + private NetworkEvent currentEvent; + + public NetworkEvent getCurrentEvent() { return currentEvent; } + public void setCurrentEvent(NetworkEvent event) { this.currentEvent = event; } + + @Override + protected void updateView(boolean fullUpdate) { + if (currentEvent != null) { + getData().setEventView(currentEvent.toView()); + } else { + getData().setEventView(null); + } + super.updateView(fullUpdate); + } + + /** Set the lobby's declared mode (Constructed / Limited) and broadcast to clients. */ + public void setLimitedMode(boolean limited) { + getData().setLimitedMode(limited); + updateView(true); + } public ServerGameLobby() { super(true); @@ -98,4 +142,307 @@ protected void onMatchOver() { FServerManager.getInstance().clearPlayerGuis(); FServerManager.getInstance().updateLobbyState(); } + + /** + * Create the in-memory event. Does not broadcast — clients see the event + * only after {@link #configureEvent} completes successfully. If the user + * cancels a sub-dialog during configure, the event is discarded without + * ever being visible to remote clients. + */ + public synchronized void createEvent(EventFormat format) { + netLog.info("Event created — format={}", format); + NetworkEvent event = new NetworkEvent(format); + setCurrentEvent(event); + } + + /** + * Configure the current event with the user's chosen pool type, pick timer, and + * pre-built draft or sealed product. The caller is responsible for popping any + * product sub-dialogs (block/set/cube/theme) before calling this — mirrors how + * the offline flow builds a {@link BoosterDraft} right after pool selection. + * On success, broadcasts the fully-configured event to clients. + * + * @param draft pre-built draft for {@link EventFormat#BOOSTER_DRAFT} events; ignored for sealed + * @return false if no current event exists or sealed pool generation failed, true otherwise + */ + public synchronized boolean configureEvent(LimitedPoolType poolType, BoosterDraft draft, + int pickTimerSeconds, int disconnectGraceSeconds) { + NetworkEvent event = getCurrentEvent(); + if (event == null) return false; + + event.setPoolType(poolType); + event.setProductDescription(poolType.toString()); + event.setPickTimerSeconds(pickTimerSeconds); + event.setDisconnectGraceSeconds(disconnectGraceSeconds); + + if (event.getFormat() == EventFormat.SEALED) { + SealedCardPoolGenerator gen = new SealedCardPoolGenerator(poolType); + if (gen.isEmpty()) return false; + event.setSealedGenerator(gen); + if (gen.getProductName() != null) { + event.setProductDescription(poolType + ": " + gen.getProductName()); + } + } else { + event.setDraft(draft); + if (draft != null && draft.getProductName() != null) { + event.setProductDescription(poolType + ": " + draft.getProductName()); + } + } + + updateView(true); + return true; + } + + /** + * Clear the current event and notify clients. Used when the host dismisses an + * in-progress new event via the panel's close control, or when switching lobby modes. + */ + public synchronized void clearCurrentEvent() { + if (getCurrentEvent() == null) return; + netLog.info("Event cleared by host"); + if (draftHost != null) { + draftHost.shutdown(); + draftHost = null; + } + setCurrentEvent(null); + getData().setActiveEventId(null); + updateView(true); + } + + /** + * Populate event participants from current lobby slots. + * Each non-OPEN slot becomes a participant: LOCAL and REMOTE are HUMAN, AI is AI. + */ + public synchronized void populateParticipants() { + NetworkEvent event = getCurrentEvent(); + if (event == null) return; + if (event.getPhase() != EventPhase.LOBBY_GATHER) { + throw new IllegalStateException("populateParticipants only valid in LOBBY_GATHER, not " + event.getPhase()); + } + event.getParticipants().clear(); + int seatIndex = 0; + for (int i = 0; i < getNumberOfSlots(); i++) { + LobbySlot slot = getSlot(i); + if (slot.getType() == LobbySlotType.OPEN) { + continue; + } + EventParticipant.Type pType = (slot.getType() == LobbySlotType.AI) + ? EventParticipant.Type.AI : EventParticipant.Type.HUMAN; + event.addParticipant(new EventParticipant(slot.getName(), pType, seatIndex, i)); + seatIndex++; + } + } + + /** + * Fill remaining seats up to targetSize with AI participants. + * AI seats are for draft pick selection only — they are not match opponents. + */ + public synchronized void fillRemainingWithAI(int targetSize) { + NetworkEvent event = getCurrentEvent(); + if (event == null) return; + int currentSize = event.getParticipants().size(); + for (int i = currentSize; i < targetSize; i++) { + String aiName = "Seat " + (i + 1); + event.addParticipant(new EventParticipant(aiName, EventParticipant.Type.AI, i, -1)); + } + } + + /** + * Shuffle draft seat positions randomly. Lobby slots and names stay the same — + * only the seat index (which determines pack-passing neighbors) is randomized. + */ + public synchronized void shuffleSeatPositions() { + NetworkEvent event = getCurrentEvent(); + if (event == null) return; + List participants = event.getParticipants(); + List seats = new ArrayList<>(); + for (EventParticipant p : participants) { + seats.add(p.getSeatIndex()); + } + Collections.shuffle(seats); + List reshuffled = new ArrayList<>(participants.size()); + for (int i = 0; i < participants.size(); i++) { + EventParticipant p = participants.get(i); + reshuffled.add(new EventParticipant(p.getName(), p.getType(), seats.get(i), p.getLobbySlotIndex())); + } + participants.clear(); + participants.addAll(reshuffled); + } + + /** + * Orchestrate the full draft startup: populate participants, create the BoosterDraft, + * configure the pod, and start. Returns UI-facing result for overlay/log setup, + * or null if draft creation fails or is cancelled. + */ + public synchronized DraftStartResult startDraftEvent() { + NetworkEvent event = getCurrentEvent(); + if (event == null) return null; + + populateParticipants(); + fillRemainingWithAI(DRAFT_POD_SIZE); + shuffleSeatPositions(); + + List participants = event.getParticipants(); + int podSize = participants.size(); + + BoosterDraft draft = event.getDraft(); + if (draft == null) return null; + + if (podSize != draft.getPodSize()) { + draft.setPodSize(podSize); + } + Set humanSeats = new HashSet<>(); + for (EventParticipant p : participants) { + if (p.isHuman()) { + humanSeats.add(p.getSeatIndex()); + } + } + draft.setHumanSeats(humanSeats); + draft.initializeBoosters(); + + int totalPacks = draft.getNumRounds(); + event.setNumRounds(totalPacks); + + // Build pod info for the UI + int hostSeatIndex = 0; + String[] names = new String[podSize]; + boolean[] aiFlags = new boolean[podSize]; + for (EventParticipant p : participants) { + int seat = p.getSeatIndex(); + if (seat >= 0 && seat < podSize) { + names[seat] = p.getName(); + aiFlags[seat] = p.isAI(); + if (p.getLobbySlotIndex() == 0) { + hostSeatIndex = seat; + } + } + } + + netLog.info("Starting draft — pod={}, humans={}, packs={}, product={}, timer={}s", + podSize, humanSeats.size(), totalPacks, + event.getProductDescription(), event.getPickTimerSeconds()); + + draftHost = new BoosterDraftHost(draft, event); + // Broadcast the fully-populated event (with participants and numRounds) so + // clients can initialize their overlay with pod names before the first pack. + updateView(true); + draftHost.start(); + + return new DraftStartResult(names, aiFlags, hostSeatIndex, totalPacks); + } + + /** + * Orchestrate sealed pool generation: populate participants and distribute pools. + */ + public synchronized void startSealedEvent() { + NetworkEvent event = getCurrentEvent(); + if (event == null) return; + netLog.info("Starting sealed — product={}", event.getProductDescription()); + populateParticipants(); + event.setPhase(EventPhase.POOL_DISTRIBUTION); + // Broadcast the now-populated event so clients see the phase change. + updateView(true); + generateAndDistributeSealedPools(); + } + + /** + * Generate sealed pools and send one to each human participant. + * Each pool is 6 boosters opened into a CardPool, wrapped in a Deck. + */ + public synchronized void generateAndDistributeSealedPools() { + NetworkEvent event = getCurrentEvent(); + if (event == null) { + netLog.warn("Cannot generate sealed pools: no event configured"); + return; + } + if (event.getFormat() != EventFormat.SEALED) { + netLog.warn("Event is not sealed format"); + return; + } + + SealedCardPoolGenerator gen = event.getSealedGenerator(); + if (gen == null || gen.isEmpty()) { + netLog.warn("No sealed generator configured"); + return; + } + + String eventId = event.getEventId(); + FServerManager server = FServerManager.getInstance(); + + for (EventParticipant participant : event.getParticipants()) { + if (participant.isAI()) { + continue; + } + + CardPool pool = gen.getCardPool(false); + if (pool == null) { + netLog.warn("Failed to generate pool for {}", participant.getName()); + continue; + } + + Deck deck = new Deck(NetworkEvent.poolNameFor(event)); + deck.getOrCreate(DeckSection.Sideboard).addAll(pool); + NetworkEvent.setEventTags(deck, event); + + server.sendToSlot(participant.getLobbySlotIndex(), + new ReceiveEventPoolEvent(eventId, deck)); + netLog.info("Sent sealed pool to {} ({} cards)", participant.getName(), pool.countAll()); + } + } + + /** + * Route an incoming draft pick from a client to the draft host. When the + * pick came over the wire, {@code expectedLobbySlot} identifies the + * submitting client's lobby slot so we can verify it owns the seat — + * otherwise any client could submit picks for anyone. Host-local picks + * (where the host is the picker) pass -1 to skip the slot check. + */ + public synchronized void handleDraftPick(DraftPickEvent pickEvent, int expectedLobbySlot) { + if (draftHost == null) { + netLog.warn("Draft pick received but no draft in progress"); + return; + } + int seat = pickEvent.getSeatIndex(); + if (expectedLobbySlot >= 0) { + int ownerSlot = findLobbySlotForSeat(seat); + if (ownerSlot != expectedLobbySlot) { + netLog.warn("Rejecting pick from lobby slot {} for seat {} (owner slot {})", + expectedLobbySlot, seat, ownerSlot); + return; + } + } + draftHost.handlePick(seat, pickEvent.getCard()); + } + + /** Lobby slot of the participant occupying the given seat, or -1 if none. */ + public synchronized int findLobbySlotForSeat(int seatIndex) { + NetworkEvent event = getCurrentEvent(); + if (event == null) return -1; + for (EventParticipant p : event.getParticipants()) { + if (p.getSeatIndex() == seatIndex) return p.getLobbySlotIndex(); + } + return -1; + } + + /** Seat index of the participant occupying the given lobby slot, or -1 if none. */ + public synchronized int findSeatForLobbySlot(int slotIndex) { + NetworkEvent event = getCurrentEvent(); + if (event == null) return -1; + for (EventParticipant p : event.getParticipants()) { + if (p.getLobbySlotIndex() == slotIndex) return p.getSeatIndex(); + } + return -1; + } + + /** Persist event selection on the lobby data and broadcast via LobbyUpdateEvent. */ + public void selectEventForMatch(String eventId, boolean deckConformance) { + getData().setActiveEventId(eventId); + getData().setActiveConformance(deckConformance); + netLog.info("Selected event for match — eventId={}, conformance={}", eventId, deckConformance); + updateView(true); + } + + public BoosterDraftHost getDraftHost() { + return draftHost; + } } diff --git a/forge-gui/src/main/java/forge/gui/interfaces/ILobbyView.java b/forge-gui/src/main/java/forge/gui/interfaces/ILobbyView.java index f3878a76499..56534ea5ffd 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/ILobbyView.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/ILobbyView.java @@ -1,8 +1,21 @@ package forge.gui.interfaces; +import forge.deck.Deck; import forge.interfaces.IPlayerChangeListener; import forge.interfaces.IUpdateable; +import forge.item.PaperCard; + +import java.util.List; public interface ILobbyView extends IUpdateable { void setPlayerChangeListener(IPlayerChangeListener iPlayerChangeListener); + + default void onDraftPackArrived(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { } + /** Fires for every pod seat (including the viewing player) so views can refresh queue depths uniformly. */ + default void onDraftSeatPicked(int seatIndex, int[] seatQueueDepths) { } + /** Fires when the pick timer expires and the server auto-selects a card. */ + default void onDraftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack) { } + /** Fires once at the end of the draft with the player's pool. */ + default void onReceiveEventPool(String eventId, Deck pool) { } } diff --git a/forge-gui/src/main/java/forge/interfaces/ILobbyListener.java b/forge-gui/src/main/java/forge/interfaces/ILobbyListener.java index b2377347226..43cb4397456 100644 --- a/forge-gui/src/main/java/forge/interfaces/ILobbyListener.java +++ b/forge-gui/src/main/java/forge/interfaces/ILobbyListener.java @@ -1,12 +1,22 @@ package forge.interfaces; +import java.util.List; + +import forge.deck.Deck; import forge.gamemodes.match.GameLobby.GameLobbyData; import forge.gamemodes.net.ChatMessage; import forge.gamemodes.net.client.ClientGameLobby; +import forge.item.PaperCard; public interface ILobbyListener { void message(String source, String message, ChatMessage.MessageType type); void update(GameLobbyData state, int slot); void close(); ClientGameLobby getLobby(); + + default void draftPackArrived(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { } + default void draftSeatPicked(int seatIndex, int[] seatQueueDepths) { } + default void draftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack) { } + default void receiveEventPool(String eventId, Deck pool) { } } diff --git a/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java b/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java index d8e692c89c0..c4ac4fea937 100644 --- a/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java +++ b/forge-gui/src/main/java/forge/itemmanager/ColumnDef.java @@ -23,6 +23,7 @@ import forge.deck.io.DeckPreferences; import forge.game.GameFormat; import forge.gamemodes.limited.CardRanker; +import forge.gamemodes.net.EventFormat; import forge.gui.card.CardPreferences; import forge.item.IPaperCard; import forge.item.InventoryItem; @@ -309,6 +310,15 @@ public enum ColumnDef { return deckEdition.getCode(); return null; }), + DECK_EVENT_TYPE("lblEventType", "lblEventType", 50, false, SortState.ASC, + from -> eventFormatDisplay(eventTag(from.getKey(), "eventFormat")), + from -> eventFormatDisplay(eventTag(from.getKey(), "eventFormat"))), + DECK_EVENT_PRODUCT("lblProduct", "lblProduct", 80, false, SortState.ASC, + from -> eventTag(from.getKey(), "eventProduct"), + from -> eventTag(from.getKey(), "eventProduct")), + DECK_EVENT_DATE("lblEventDate", "lblEventDate", 60, false, SortState.DESC, + from -> eventTag(from.getKey(), "eventDate"), + from -> eventTag(from.getKey(), "eventDate")), DECK_AI("lblAI", "lblAIStatus", 38, true, SortState.DESC, from -> toDeck(from.getKey()).getAI().inMainDeck, from -> toDeck(from.getKey()).getAI()), @@ -441,6 +451,25 @@ private static DeckProxy toDeck(final InventoryItem i) { return i instanceof DeckProxy ? ((DeckProxy) i) : null; } + private static String eventTag(final InventoryItem i, final String key) { + DeckProxy d = toDeck(i); + if (d == null) return ""; + String v = DeckProxy.getEventTag(d.getDeck(), key); + return v != null ? v : ""; + } + + private static String eventFormatDisplay(final String rawFormat) { + if (rawFormat.isEmpty()) return ""; + Localizer localizer = Localizer.getInstance(); + if (EventFormat.BOOSTER_DRAFT.name().equals(rawFormat)) { + return localizer.getMessage("lblNetworkModeDraft"); + } + if (EventFormat.SEALED.name().equals(rawFormat)) { + return localizer.getMessage("lblNetworkModeSealed"); + } + return rawFormat; + } + private static ColorSet toDeckColor(final InventoryItem i) { return i instanceof DeckProxy ? ((DeckProxy) i).getColor() : null; } diff --git a/forge-gui/src/main/java/forge/itemmanager/ItemManagerConfig.java b/forge-gui/src/main/java/forge/itemmanager/ItemManagerConfig.java index 8fd6740bfb3..f787864079d 100644 --- a/forge-gui/src/main/java/forge/itemmanager/ItemManagerConfig.java +++ b/forge-gui/src/main/java/forge/itemmanager/ItemManagerConfig.java @@ -101,6 +101,8 @@ public enum ItemManagerConfig { null, null, 3, 0), SEALED_DECKS(SColumnUtil.getDecksDefaultColumns(true, false), false, false, false, null, null, 3, 0), + NET_EVENT_DECKS(SColumnUtil.getNetworkEventDeckColumns(true), false, false, false, + null, null, 3, 0), WINSTON_DECKS(SColumnUtil.getDecksDefaultColumns(true, false), false, false, false, null, null, 3, 0), QUEST_DECKS(SColumnUtil.getDecksDefaultColumns(true, false), false, false, false, diff --git a/forge-gui/src/main/java/forge/itemmanager/SColumnUtil.java b/forge-gui/src/main/java/forge/itemmanager/SColumnUtil.java index 0ae2e4a250e..bfb37eda5fc 100644 --- a/forge-gui/src/main/java/forge/itemmanager/SColumnUtil.java +++ b/forge-gui/src/main/java/forge/itemmanager/SColumnUtil.java @@ -235,6 +235,27 @@ public static Map getConquestCommandersDefaultColum return columns; } + public static Map getNetworkEventDeckColumns(boolean allowEdit) { + List colDefs = new ArrayList<>(); + colDefs.add(ColumnDef.DECK_FAVORITE); + if (allowEdit) { + colDefs.add(ColumnDef.DECK_ACTIONS); + } + colDefs.add(ColumnDef.NAME); + colDefs.add(ColumnDef.DECK_COLOR); + colDefs.add(ColumnDef.DECK_EVENT_TYPE); + colDefs.add(ColumnDef.DECK_EVENT_PRODUCT); + colDefs.add(ColumnDef.DECK_EVENT_DATE); + colDefs.add(ColumnDef.DECK_MAIN); + colDefs.add(ColumnDef.DECK_SIDE); + + Map columns = getColumns(colDefs); + columns.get(ColumnDef.DECK_EVENT_DATE).setSortPriority(1); + columns.get(ColumnDef.DECK_FAVORITE).setSortPriority(2); + columns.get(ColumnDef.NAME).setSortPriority(3); + return columns; + } + public static Map getDecksDefaultColumns(boolean allowEdit, boolean includeFolder) { List colDefs = new ArrayList<>(); colDefs.add(ColumnDef.DECK_FAVORITE); diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java index 46de30bc89a..4ab8b955417 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -261,6 +261,7 @@ public final class ForgeConstants { public static final String DECK_OATHBREAKER_DIR = DECK_BASE_DIR + "oathbreaker" + PATH_SEPARATOR; public static final String DECK_NET_DIR = DECK_BASE_DIR + "net" + PATH_SEPARATOR; public static final String DECK_NET_ARCHIVE_DIR = DECK_BASE_DIR + "archive" + PATH_SEPARATOR; + public static final String DECK_NET_EVENT_DIR = DECK_BASE_DIR + "network-events" + PATH_SEPARATOR; public static final String QUEST_SAVE_DIR = USER_QUEST_DIR + "saves" + PATH_SEPARATOR; public static final String CONQUEST_SAVE_DIR = USER_CONQUEST_DIR + "saves" + PATH_SEPARATOR; public static final String DECK_TINY_LEADERS_DIR = DECK_BASE_DIR + "tiny_leaders" + PATH_SEPARATOR; @@ -319,6 +320,7 @@ public final class ForgeConstants { DECK_COMMANDER_DIR, DECK_OATHBREAKER_DIR, DECK_NET_DIR, + DECK_NET_EVENT_DIR, QUEST_SAVE_DIR, CACHE_TOKEN_PICS_DIR, CACHE_ICON_PICS_DIR, diff --git a/forge-gui/src/main/java/forge/model/CardCollections.java b/forge-gui/src/main/java/forge/model/CardCollections.java index 24ea118d045..ac7e237ff3f 100644 --- a/forge-gui/src/main/java/forge/model/CardCollections.java +++ b/forge-gui/src/main/java/forge/model/CardCollections.java @@ -47,6 +47,7 @@ public class CardCollections { private IStorage brawl; private IStorage genetic; private IStorage customStarter; + private IStorage networkEvent; public CardCollections() { } @@ -164,4 +165,19 @@ public final IStorage getCustomStarterDecks() { } return customStarter; } + + public final IStorage getNetworkEventDecks() { + if (networkEvent == null) { + networkEvent = new StorageImmediatelySerialized<>("Network event decks", + new DeckStorage(new File(ForgeConstants.DECK_NET_EVENT_DIR), + ForgeConstants.DECK_BASE_DIR)); + } + return networkEvent; + } + + /** Drops the cached network-event deck storage so the next + * {@link #getNetworkEventDecks()} re-reads from disk. */ + public final void reloadNetworkEventDecks() { + networkEvent = null; + } }