diff --git a/forge-gui-mobile/src/forge/deck/FDeckChooser.java b/forge-gui-mobile/src/forge/deck/FDeckChooser.java index abeeb172f82..ec1ec9bdfa6 100644 --- a/forge-gui-mobile/src/forge/deck/FDeckChooser.java +++ b/forge-gui-mobile/src/forge/deck/FDeckChooser.java @@ -560,6 +560,7 @@ public void initialize(FPref savedStateSetting, DeckType defaultDeckType) { cmbDeckTypes.addItem(DeckType.PIONEER_CARDGEN_DECK); cmbDeckTypes.addItem(DeckType.HISTORIC_CARDGEN_DECK); } + cmbDeckTypes.addItem(DeckType.NET_EVENT_DECK); cmbDeckTypes.addItem(DeckType.NET_DECK); cmbDeckTypes.addItem(DeckType.NET_ARCHIVE_STANDARD_DECK); cmbDeckTypes.addItem(DeckType.NET_ARCHIVE_PIONEER_DECK); @@ -601,6 +602,7 @@ public void initialize(FPref savedStateSetting, DeckType defaultDeckType) { cmbDeckTypes.addItem(DeckType.PRECONSTRUCTED_DECK); cmbDeckTypes.addItem(DeckType.PRECON_COMMANDER_DECK); cmbDeckTypes.addItem(DeckType.QUEST_OPPONENT_DECK); + cmbDeckTypes.addItem(DeckType.NET_EVENT_DECK); cmbDeckTypes.addItem(DeckType.NET_DECK); cmbDeckTypes.addItem(DeckType.NET_COMMANDER_DECK); cmbDeckTypes.addItem(DeckType.NET_ARCHIVE_STANDARD_DECK); @@ -1058,6 +1060,10 @@ private void refreshDecksList(DeckType deckType, boolean forceRefresh, FEvent ev pool = DeckProxy.getNetArchiveBlockDecks(NetDeckArchiveBlock); config = ItemManagerConfig.NET_ARCHIVE_BLOCK_DECKS; break; + case NET_EVENT_DECK: + pool = DeckProxy.getAllNetworkEventDecks(); + config = ItemManagerConfig.NET_EVENT_DECKS; + break; case NET_DECK: case NET_COMMANDER_DECK: if (netDeckCategory != null) { @@ -1356,12 +1362,7 @@ private DeckType getDeckTypeFromSavedState(String savedState) { NetDeckArchiveBlock = NetDeckArchiveBlock.selectAndLoad(lstDecks.getGameType(), deckType.substring(NetDeckArchiveBlock.PREFIX.length())); return DeckType.NET_ARCHIVE_BLOCK_DECK; } - DeckType resolved = DeckType.valueOf(deckType); - // TODO: remove when network draft/sealed support is added to mobile. - if (resolved == DeckType.NET_EVENT_DECK) { - return selectedDeckType; - } - return resolved; + return DeckType.valueOf(deckType); } } catch (IllegalArgumentException ex) { diff --git a/forge-gui-mobile/src/forge/deck/FDeckEditor.java b/forge-gui-mobile/src/forge/deck/FDeckEditor.java index 37ec2b70b67..25b5de94047 100644 --- a/forge-gui-mobile/src/forge/deck/FDeckEditor.java +++ b/forge-gui-mobile/src/forge/deck/FDeckEditor.java @@ -1,5 +1,6 @@ package forge.deck; +import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Input.Keys; import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.utils.Align; @@ -34,6 +35,7 @@ import forge.model.FModel; import forge.screens.FScreen; import forge.screens.TabPageScreen; +import forge.screens.match.views.VChat; import forge.screens.match.views.VLog; import forge.toolbox.*; import forge.toolbox.FEvent.FEventHandler; @@ -331,6 +333,10 @@ public List getBasicLandSets(Deck currentDeck) { public static DeckEditorConfig EditorConfigSealed = new GameTypeDeckEditorConfig(GameType.Sealed, new FileDeckGroupController(FModel.getDecks().getSealed(), DeckGroup::new, DeckPreferences::setSealedDeck)) .setSideboardConfig(ItemManagerConfig.SEALED_POOL); + /** Editor for network-event sealed/draft pools stored in {@code getNetworkEventDecks()}. */ + public static DeckEditorConfig EditorConfigNetworkEventPool = new GameTypeDeckEditorConfig(GameType.Sealed, + new FileDeckController<>(FModel.getDecks().getNetworkEventDecks(), Deck::new, null)) + .setSideboardConfig(ItemManagerConfig.SEALED_POOL); public static DeckEditorConfig EditorConfigWinston = new GameTypeDeckEditorConfig(GameType.Winston, new FileDeckGroupController(FModel.getDecks().getWinston(), DeckGroup::new, null)); public static DeckEditorConfig EditorConfigCommander = new GameTypeDeckEditorConfig(GameType.Commander, @@ -747,7 +753,7 @@ public void notifyNewControllerModel() { public Deck getDeck() { return deck; } - private void setDeck(Deck deck) { + public void setDeck(Deck deck) { if (this.deck == deck) { return; } this.deck = deck; setHeaderText(getDeckController().getDeckDisplayName()); @@ -1123,6 +1129,7 @@ protected static class DeckHeader extends FContainer { protected final FLabel btnSave; protected final FLabel btnMoreOptions; protected FDisplayObject btnDraftLog; + protected ChatHeaderButton btnChat; protected DeckHeader() { setHeight(HEADER_HEIGHT); @@ -1171,13 +1178,21 @@ protected List layoutHeaderElements(float height, float availabl float width = Math.max(remainingWidth / 4, Math.min(height * 4, remainingWidth / 2)); btnDraftLog.setSize(width, height); out.add(btnDraftLog); + remainingWidth -= width; + } + if(btnChat != null) { + float width = Math.max(remainingWidth / 4, Math.min(height * 4, remainingWidth / 2)); + btnChat.setSize(width, height); + out.add(btnChat); } return out; } public void initDraftLog(GameLog draftLog, FContainer parentScreen) { - VLog draftLogContainer = new VLog(() -> draftLog); + VLog draftLogContainer = new VLog(() -> draftLog, true); draftLogContainer.setDropDownContainer(parentScreen); + // Live-refresh the open dropdown so a remote player's pick shows without local interaction + draftLog.addObserver((o, arg) -> FThreads.invokeInEdtNowOrLater(draftLogContainer::refresh)); this.btnDraftLog = new FLabel.ButtonBuilder() .text(Localizer.getInstance().getMessage("lblEditorLog")) .pressedColor(Header.getBtnPressedColor()) @@ -1187,6 +1202,47 @@ public void initDraftLog(GameLog draftLog, FContainer parentScreen) { draftLogContainer.setDropdownOwner(btnDraftLog); this.add(btnDraftLog); } + + public VChat initChat(FContainer parentScreen) { + VChat chat = new VChat(); + chat.setDropDownContainer(parentScreen); + this.btnChat = new ChatHeaderButton(new FLabel.ButtonBuilder() + .text(Localizer.getInstance().getMessage("lblChat")) + .pressedColor(Header.getBtnPressedColor()) + .command((e) -> chat.show()) + .font(FSkinFont.get(20))); + chat.setDropdownOwner(btnChat); + this.add(btnChat); + return chat; + } + } + + /** Header chat button that renders the unread-message badge shared with the in-match chat tab. */ + protected static class ChatHeaderButton extends FLabel implements IUnreadIndicator { + private int unreadCount; + + protected ChatHeaderButton(Builder builder) { + super(builder); + } + + @Override + public void incrementUnread() { + unreadCount++; + Gdx.graphics.requestRendering(); + } + + @Override + public void clearUnread() { + if (unreadCount == 0) { return; } + unreadCount = 0; + Gdx.graphics.requestRendering(); + } + + @Override + public void draw(Graphics g) { + super.draw(g); + FMenuTab.drawUnreadBadge(g, unreadCount, getWidth()); + } } protected static abstract class DeckEditorPage extends TabPage { @@ -1911,14 +1967,14 @@ protected boolean cardIsFavorite(PaperCard card) { } } - protected static class DeckSectionPage extends CardManagerPage { + public static class DeckSectionPage extends CardManagerPage { private final String captionPrefix; - protected final DeckSection deckSection; + public final DeckSection deckSection; protected DeckSectionPage(CardManager cardManager, DeckSection deckSection) { this(cardManager, deckSection, ItemManagerConfig.DECK_EDITOR); } - protected DeckSectionPage(CardManager cardManager, DeckSection deckSection, ItemManagerConfig config) { + public DeckSectionPage(CardManager cardManager, DeckSection deckSection, ItemManagerConfig config) { this(cardManager, deckSection, config, deckSection.getLocalizedShortName(), iconFromDeckSection(deckSection)); } protected DeckSectionPage(CardManager cardManager, DeckSection deckSection, ItemManagerConfig config, String caption, FImage icon) { diff --git a/forge-gui-mobile/src/forge/menu/FMenuTab.java b/forge-gui-mobile/src/forge/menu/FMenuTab.java index e0d93a97ebb..fde438b0c53 100644 --- a/forge-gui-mobile/src/forge/menu/FMenuTab.java +++ b/forge-gui-mobile/src/forge/menu/FMenuTab.java @@ -14,7 +14,7 @@ import forge.toolbox.FDisplayObject; import forge.util.Utils; -public class FMenuTab extends FDisplayObject { +public class FMenuTab extends FDisplayObject implements IUnreadIndicator { public static final FSkinFont FONT = FSkinFont.get(12); boolean iconOnly = false; boolean active = false; @@ -195,16 +195,19 @@ public void draw(Graphics g) { } else g.drawText(text, FONT, foreColor, x, y, w, h, false, Align.center, true); - //unread badge in the top-right corner - if (unreadCount > 0) { - String label = unreadCount > 99 ? "99+" : Integer.toString(unreadCount); - float textW = BADGE_FONT.getBounds(label).width; - float diameter = Math.max(BADGE_FONT.getLineHeight(), textW + 2 * Utils.scale(4)); - float bx = getWidth() - diameter - PADDING; - float by = PADDING; - g.fillRect(BADGE_COLOR, bx, by, diameter, diameter); - g.drawText(label, BADGE_FONT, BADGE_TEXT_COLOR, bx, by, diameter, diameter, false, Align.center, true); - } + drawUnreadBadge(g, unreadCount, getWidth()); + } + + /** Draws the unread-message count badge in the top-right corner of a widget of the given width. */ + public static void drawUnreadBadge(Graphics g, int unreadCount, float widgetWidth) { + if (unreadCount <= 0) { return; } + String label = unreadCount > 99 ? "99+" : Integer.toString(unreadCount); + float textW = BADGE_FONT.getBounds(label).width; + float diameter = Math.max(BADGE_FONT.getLineHeight(), textW + 2 * Utils.scale(4)); + float bx = widgetWidth - diameter - PADDING; + float by = PADDING; + g.fillRect(BADGE_COLOR, bx, by, diameter, diameter); + g.drawText(label, BADGE_FONT, BADGE_TEXT_COLOR, bx, by, diameter, diameter, false, Align.center, true); } public boolean isShowingDropdownMenu(boolean any) { if (dropDown == null) diff --git a/forge-gui-mobile/src/forge/menu/IUnreadIndicator.java b/forge-gui-mobile/src/forge/menu/IUnreadIndicator.java new file mode 100644 index 00000000000..7f732494d59 --- /dev/null +++ b/forge-gui-mobile/src/forge/menu/IUnreadIndicator.java @@ -0,0 +1,7 @@ +package forge.menu; + +/** A tab or button that tracks and displays a count of unread chat messages. */ +public interface IUnreadIndicator { + void incrementUnread(); + void clearUnread(); +} diff --git a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java index 05b43c4b45c..7e8feb05a68 100644 --- a/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java +++ b/forge-gui-mobile/src/forge/screens/constructed/LobbyScreen.java @@ -55,7 +55,7 @@ public abstract class LobbyScreen extends LaunchScreen implements ILobbyView { private static final ForgePreferences prefs = FModel.getPreferences(); - private static final float PADDING = Utils.scale(5); + protected static final float PADDING = Utils.scale(5); public static final int MAX_PLAYERS = 4; private static final FSkinFont VARIANTS_FONT = FSkinFont.get(12); @@ -913,6 +913,11 @@ protected void setLobbyControlsVisible(boolean visible) { playersScroll.setVisible(visible); } + protected void setVariantsVisible(boolean visible) { + lblVariants.setVisible(visible); + cbVariants.setVisible(visible); + } + public void setStartButtonAvailability() { if (lobby.isAllowNetworking() && FServerManager.getInstance() != null) btnStart.setVisible(FServerManager.getInstance().isHosting()); diff --git a/forge-gui-mobile/src/forge/screens/limited/LoadDraftScreen.java b/forge-gui-mobile/src/forge/screens/limited/LoadDraftScreen.java index 9bba36f5872..3c7e0bb7c55 100644 --- a/forge-gui-mobile/src/forge/screens/limited/LoadDraftScreen.java +++ b/forge-gui-mobile/src/forge/screens/limited/LoadDraftScreen.java @@ -16,6 +16,7 @@ import forge.game.GameType; import forge.game.player.RegisteredPlayer; import forge.gamemodes.match.HostedMatch; +import forge.gamemodes.net.EventFormat; import forge.gui.FThreads; import forge.gui.GuiBase; import forge.gui.util.SGuiChoose; @@ -69,7 +70,13 @@ public LoadDraftScreen() { @Override public void onActivate() { - lstDecks.setPool(DeckProxy.getAllDraftDecks()); + List combined = new ArrayList<>(DeckProxy.getAllDraftDecks()); + for (DeckProxy p : DeckProxy.getAllNetworkEventDecks()) { + if (EventFormat.BOOSTER_DRAFT.name().equals(DeckProxy.getEventTag(p.getDeck(), "eventFormat"))) { + combined.add(p); + } + } + lstDecks.setPool(combined); lstDecks.setSelectedString(DeckPreferences.getDraftDeck()); cbGamesInMatchBinder.load(); } @@ -120,6 +127,15 @@ protected void startMatch() { return; } + // Event decks lack a DeckGroup for gauntlet/AI play + if (FModel.getDecks().getDraft().get(humanDeck.getName()) == null) { + FThreads.invokeInEdtLater(() -> + FOptionPane.showErrorDialog( + Forge.getLocalizer().getMessage("lblEventDeckEditOnly"), + Forge.getLocalizer().getMessage("lblNoOpponentsForEventDeck"))); + return; + } + // TODO: if booster draft tournaments are supported in the future, add the possibility to choose them here final boolean gauntlet = cbMode.getSelectedItem().equals(Forge.getLocalizer().getMessage("lblGauntlet")); diff --git a/forge-gui-mobile/src/forge/screens/limited/LoadSealedScreen.java b/forge-gui-mobile/src/forge/screens/limited/LoadSealedScreen.java index d0bdb59f981..40220049b0c 100644 --- a/forge-gui-mobile/src/forge/screens/limited/LoadSealedScreen.java +++ b/forge-gui-mobile/src/forge/screens/limited/LoadSealedScreen.java @@ -16,6 +16,7 @@ import forge.game.GameType; import forge.game.player.RegisteredPlayer; import forge.gamemodes.match.HostedMatch; +import forge.gamemodes.net.EventFormat; import forge.gui.FThreads; import forge.gui.GuiBase; import forge.gui.util.SGuiChoose; @@ -69,7 +70,13 @@ public LoadSealedScreen() { @Override public void onActivate() { - lstDecks.setPool(DeckProxy.getAllSealedDecks()); + List combined = new ArrayList<>(DeckProxy.getAllSealedDecks()); + for (DeckProxy p : DeckProxy.getAllNetworkEventDecks()) { + if (EventFormat.SEALED.name().equals(DeckProxy.getEventTag(p.getDeck(), "eventFormat"))) { + combined.add(p); + } + } + lstDecks.setPool(combined); lstDecks.setSelectedString(DeckPreferences.getSealedDeck()); cbGamesInMatchBinder.load(); } @@ -118,6 +125,15 @@ protected void startMatch() { return; } + // Event decks lack a DeckGroup for gauntlet/AI play + if (FModel.getDecks().getSealed().get(humanDeck.getName()) == null) { + FThreads.invokeInEdtLater(() -> + FOptionPane.showErrorDialog( + Forge.getLocalizer().getMessage("lblEventDeckEditOnly"), + Forge.getLocalizer().getMessage("lblNoOpponentsForEventDeck"))); + return; + } + final boolean gauntlet = cbMode.getSelectedItem().equals(Forge.getLocalizer().getMessage("lblGauntlet")); if (gauntlet) { diff --git a/forge-gui-mobile/src/forge/screens/limited/NetworkDraftLog.java b/forge-gui-mobile/src/forge/screens/limited/NetworkDraftLog.java new file mode 100644 index 00000000000..717602b228b --- /dev/null +++ b/forge-gui-mobile/src/forge/screens/limited/NetworkDraftLog.java @@ -0,0 +1,86 @@ +package forge.screens.limited; + +import forge.deck.FDeckEditor.FDraftLog; +import forge.gamemodes.net.EventParticipant; +import forge.item.PaperCard; +import forge.util.Localizer; + +import java.util.List; + +/** + * Pushes network draft log entries into the mobile {@link FDraftLog} panel, + * caching each pending self-pick until the server echoes its queue depth. + */ +public final class NetworkDraftLog { + private static final Localizer localizer = Localizer.getInstance(); + + private final int ownSeat; + private FDraftLog sink; + + private record PendingSelfPick(String cardName, int packNumber, int pickInPack, boolean auto) { } + private PendingSelfPick pending; + + public NetworkDraftLog(int ownSeat) { + this.ownSeat = ownSeat; + } + + public void setSink(FDraftLog sink) { + this.sink = sink; + } + + /** + * Called when any seat confirms a pick (server's DraftSeatPickedEvent). + * Flushes the pending self-pick entry for our own seat with server-authoritative + * queue depth; logs an "other picked" entry for all other seats. + */ + public void recordSeatPicked(int seat, int[] seatQueueDepths, + List participants) { + int depth = (seat >= 0 && seat < seatQueueDepths.length) ? seatQueueDepths[seat] : 0; + if (seat == ownSeat) { + flushPending(depth); + } else { + String name = EventParticipant.resolveName(seat, participants, null); + log(localizer.getMessage("lblDraftLogOtherPick", name) + waitingSuffix(depth)); + } + } + + /** + * Called when the server auto-picks a card for our seat (DraftAutoPickedEvent). + * Caches a pending entry; flushed with authoritative queue depth on the next + * recordSeatPicked for our seat. + */ + public void recordAutoPicked(int seat, PaperCard card, int packNumber, int pickInPack, + List participants) { + if (seat != ownSeat) return; + pending = new PendingSelfPick(card.getName(), packNumber, pickInPack, true); + } + + /** + * Caches a pending entry for a manual pick by the local player. Flushed when + * the server's DraftSeatPickedEvent echoes our seat back with queue-depth data. + */ + public void recordPendingSelfPick(PaperCard card, int packNumber, int pickInPack) { + pending = new PendingSelfPick(card.getName(), packNumber, pickInPack, false); + } + + private void flushPending(int queueDepth) { + PendingSelfPick p = pending; + if (p == null) return; + pending = null; + String displayName = p.auto ? p.cardName + " (auto)" : p.cardName; + String msg = localizer.getMessage("lblDraftLogMyPick", displayName, + String.valueOf(p.packNumber), String.valueOf(p.pickInPack)); + log(msg + waitingSuffix(queueDepth)); + } + + private static String waitingSuffix(int depth) { + if (depth <= 0) return ""; + return " " + localizer.getMessage("lblDraftLogWaiting", String.valueOf(depth)); + } + + private void log(String message) { + if (sink != null) { + sink.addLogEntry(message); + } + } +} diff --git a/forge-gui-mobile/src/forge/screens/limited/NetworkDraftingProcessScreen.java b/forge-gui-mobile/src/forge/screens/limited/NetworkDraftingProcessScreen.java new file mode 100644 index 00000000000..1282a4d636c --- /dev/null +++ b/forge-gui-mobile/src/forge/screens/limited/NetworkDraftingProcessScreen.java @@ -0,0 +1,477 @@ +package forge.screens.limited; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import com.badlogic.gdx.utils.Align; + +import forge.Forge; +import forge.Graphics; +import forge.assets.FSkinColor; +import forge.assets.FSkinFont; +import forge.assets.FSkinImage; +import forge.deck.Deck; +import forge.deck.DeckSection; +import forge.deck.FDeckEditor; +import forge.deck.FDeckEditor.DeckEditorConfig; +import forge.deck.FDeckEditor.FDraftLog; +import forge.game.GameType; +import forge.gamemodes.net.EventParticipant; +import forge.gamemodes.net.event.DraftPickEvent; +import forge.gui.FThreads; +import forge.item.PaperCard; +import forge.itemmanager.CardManager; +import forge.itemmanager.ItemManagerConfig; +import forge.screens.FScreen; +import forge.screens.match.views.VChat; +import forge.toolbox.DraftTimerRope; +import forge.toolbox.FDisplayObject; +import forge.toolbox.FLabel; +import forge.toolbox.FOptionPane; +import forge.util.Utils; + +/** + * Push-model network draft picker. Packs arrive via the lobby's forwarded + * draftPackArrived call (this screen does NOT implement IDraftEventHandler — + * the lobby owns the single handler registration). + * + * Extends FDeckEditor for the Pack N / Main (N) / Side (N) tab layout, deck + * header, and draft-log dropdown. + */ +public final class NetworkDraftingProcessScreen extends FDeckEditor { + + private final int seatIndex; + // Pod seats for the direction strip and log; from the host's NetworkEvent or, on a + // client, the broadcast NetworkEventView (a client has no NetworkEvent). + private final List participants; + private final Consumer pickSender; + private final Runnable onLeave; + private final NetworkDraftLog draftLog; + + private final FDraftLog draftLogBuffer = new FDraftLog(); + private final VChat chat; + + private NetworkDraftPackPage networkPackPage; + private PicksDeckSectionPage mainPicksPage; + private int currentPackNumber; + private int currentPickNumber; + private boolean draftComplete; + private PaperCard pendingPickCard; + private int[] lastQueueDepths; + + public NetworkDraftingProcessScreen(int seatIndex, List participants, + Consumer pickSender, Runnable onLeave) { + super(new NetworkDraftEditorConfig(), new PicksDeckController(new Deck())); + + this.seatIndex = seatIndex; + this.participants = participants; + this.pickSender = pickSender; + this.onLeave = onLeave; + this.draftLog = new NetworkDraftLog(seatIndex); + + draftLog.setSink(draftLogBuffer); + deckHeader.initDraftLog(draftLogBuffer, this); + chat = deckHeader.initChat(this); + + for (TabPage page : tabPages) { + if (page instanceof NetworkDraftPackPage ndpp) { + networkPackPage = ndpp; + } else if (page instanceof PicksDeckSectionPage pdsp && pdsp.deckSection == DeckSection.Main) { + mainPicksPage = pdsp; + } + } + networkPackPage.setPickHandler(this::onPackCardActivated); + } + + @Override + public FScreen getLandscapeBackdropScreen() { + return null; + } + + @Override + public boolean isDrafting() { + return !draftComplete; + } + + void onPackCardActivated() { + PaperCard picked = networkPackPage.getSelectedCard(); + if (picked == null) return; + pendingPickCard = picked; + draftLog.recordPendingSelfPick(picked, currentPackNumber, currentPickNumber); + networkPackPage.clearPack(); + pickSender.accept(new DraftPickEvent(seatIndex, picked)); + } + + public void onPackArrived(List pack, int packNumber, int pickNumber, + int timerSeconds) { + FThreads.invokeInEdtNowOrLater(() -> { + pendingPickCard = null; // defensive clear in case prior onSeatPicked never fired + currentPackNumber = packNumber; + currentPickNumber = pickNumber; + int podSize = participants.size(); + if (lastQueueDepths == null || lastQueueDepths.length != podSize) { + // Seed each seat with one pack until the first SeatPicked broadcast arrives + lastQueueDepths = new int[podSize]; + Arrays.fill(lastQueueDepths, 1); + } + networkPackPage.setPushedPack(pack, packNumber, timerSeconds); + networkPackPage.updateDirection(seatIndex, participants, + lastQueueDepths, isPassingRight(packNumber)); + setSelectedPage(networkPackPage); + }); + } + + public void onSeatPicked(int seat, int[] queueDepths) { + draftLog.recordSeatPicked(seat, queueDepths, participants); + lastQueueDepths = queueDepths.clone(); + networkPackPage.updateDirection(seatIndex, participants, + lastQueueDepths, isPassingRight(currentPackNumber)); + if (seat == seatIndex && pendingPickCard != null) { + addPickToMain(pendingPickCard); + pendingPickCard = null; + } + } + + /** Odd packs pass right, even packs pass left (conventional booster draft). */ + private static boolean isPassingRight(int packNumber) { + return packNumber % 2 == 1; + } + + public void onAutoPicked(int seat, PaperCard card, int packNumber, int pickInPack) { + draftLog.recordAutoPicked(seat, card, packNumber, pickInPack, participants); + if (seat == seatIndex) { + addPickToMain(card); + networkPackPage.stopTimer(); + } + } + + public void onDraftCompleted() { + draftComplete = true; + networkPackPage.stopTimer(); + } + + private void addPickToMain(PaperCard card) { + FThreads.invokeInEdtNowOrLater(() -> { + if (mainPicksPage != null) { + mainPicksPage.addPick(card); + } + }); + } + + @Override + public void onClose(Consumer canCloseCallback) { + if (draftComplete || canCloseCallback == null) { + chat.unsubscribe(); + super.onClose(canCloseCallback); + return; + } + FOptionPane.showConfirmDialog( + Forge.getLocalizer().getMessage("lblEndDraftConfirm"), + Forge.getLocalizer().getMessage("lblLeaveDraft"), + Forge.getLocalizer().getMessage("lblLeave"), + Forge.getLocalizer().getMessage("lblCancel"), + false, + confirmed -> { + if (confirmed) { + chat.unsubscribe(); + onLeave.run(); + } + canCloseCallback.accept(confirmed); + }); + } + + // Push-model pack catalog with an integrated pick timer + static final class NetworkDraftPackPage extends DraftPackPage { + private static final float ROPE_HEIGHT = Utils.scale(6); + private static final float LABEL_HEIGHT = Utils.scale(18); + private static final float COUNTDOWN_WIDTH = Utils.scale(44); + private static final float GAP = Utils.scale(4); + private static final float STRIP_HEIGHT = Utils.scale(20); + + private final DraftTimerRope timerRope = new DraftTimerRope(); + private final FLabel lblCountdown = new FLabel.Builder() + .font(FSkinFont.get(11)).align(Align.left).build(); + private final DraftDirectionStrip directionStrip = new DraftDirectionStrip(); + private Runnable pickHandler; + + NetworkDraftPackPage() { + super(new CardManager(false)); + cardManager.setShowRanking(true); + add(timerRope); + add(lblCountdown); + add(directionStrip); + } + + void updateDirection(int mySeat, List participants, int[] depths, boolean passingRight) { + directionStrip.update(mySeat, participants, depths, passingRight); + } + + void setPickHandler(Runnable handler) { + this.pickHandler = handler; + cardManager.setItemActivateHandler(e -> { + if (pickHandler != null) pickHandler.run(); + }); + } + + void setPushedPack(List pack, int packNumber, int timerSeconds) { + String label = Forge.getLocalizer().getMessage("lblPackN", String.valueOf(packNumber)); + caption = label; + cardManager.setCaption(label); + cardManager.setPool(pack); + cardManager.setEnabled(true); + showTab(); + if (timerSeconds > 0) { + timerRope.start(timerSeconds); + lblCountdown.setVisible(true); + } else { + timerRope.stop(); + lblCountdown.setVisible(false); + } + } + + void clearPack() { + cardManager.setPool(Collections.emptyList()); + cardManager.setEnabled(false); + timerRope.stop(); + lblCountdown.setVisible(false); + } + + void stopTimer() { + timerRope.stop(); + lblCountdown.setVisible(false); + } + + PaperCard getSelectedCard() { + return cardManager.getSelectedItem(); + } + + /** No-op — pool arrives via push, not pulled from a BoosterDraft. */ + @Override + public void refresh() {} + + /** No-op — picks are confirmed server-side; addPickToMain handles the local state. */ + @Override + public void moveCard(PaperCard card, CardManagerPage destination, int qty) {} + + @Override + public void draw(Graphics g) { + int secs = timerRope.getRemainingSeconds(); + lblCountdown.setText(String.format("%02d:%02d", secs / 60, secs % 60)); + super.draw(g); + } + + @Override + protected void doLayout(float width, float height) { + lblCountdown.setBounds(GAP, 0, COUNTDOWN_WIDTH, LABEL_HEIGHT); + float ropeX = COUNTDOWN_WIDTH + 2 * GAP; + timerRope.setBounds(ropeX, (LABEL_HEIGHT - ROPE_HEIGHT) / 2f, width - ropeX - GAP, ROPE_HEIGHT); + directionStrip.setBounds(0, LABEL_HEIGHT, width, STRIP_HEIGHT); + float top = LABEL_HEIGHT + STRIP_HEIGHT; + cardManager.setBounds(0, top, width, height - top); + } + } + + // Neighbor pack-passing strip: left / YOU / right names, queue-depth icons on each + // seat's incoming side, and arrows showing the pass direction for the current pack. + static final class DraftDirectionStrip extends FDisplayObject { + private static final float ICON_HEIGHT = Utils.scale(15); + private static final float ARROW_WIDTH = Utils.scale(7); + private static final float ARROW_HALF_HEIGHT = Utils.scale(4); + private static final float SEG_GAP = Utils.scale(4); + private static final FSkinFont FONT = FSkinFont.get(11); + + private enum Kind { TEXT, ICONS, ARROW } + + private final List items = new ArrayList<>(8); + private float[] widths = new float[0]; + private float totalWidth; + private boolean hasData; + + /** One drawable element: a name run, a stack of {@code depth} pack icons, or a direction arrow. */ + private static final class Item { + final Kind kind; + final String text; + final int depth; + final boolean arrowRight; + + private Item(Kind kind, String text, int depth, boolean arrowRight) { + this.kind = kind; + this.text = text; + this.depth = depth; + this.arrowRight = arrowRight; + } + + static Item text(String t) { return new Item(Kind.TEXT, t, 0, false); } + static Item icons(int depth) { return new Item(Kind.ICONS, null, depth, false); } + static Item arrow(boolean right) { return new Item(Kind.ARROW, null, 0, right); } + } + + void update(int mySeat, List participants, int[] depths, boolean passingRight) { + int podSize = participants == null ? 0 : participants.size(); + if (podSize < 2 || depths == null || depths.length != podSize + || mySeat < 0 || mySeat >= podSize) { + hasData = false; + return; + } + int leftIdx = (mySeat - 1 + podSize) % podSize; + int rightIdx = (mySeat + 1) % podSize; + String you = Forge.getLocalizer().getMessage("lblDraftOverlayYou"); + String left = EventParticipant.resolveName(leftIdx, participants, null); + String right = EventParticipant.resolveName(rightIdx, participants, null); + + // Icons sit on each seat's incoming side: left when passing right, right when passing left + items.clear(); + if (passingRight) { + addIcons(depths[leftIdx]); + items.add(Item.text(left)); + items.add(Item.arrow(true)); + addIcons(depths[mySeat]); + items.add(Item.text(you)); + items.add(Item.arrow(true)); + addIcons(depths[rightIdx]); + items.add(Item.text(right)); + } else { + items.add(Item.text(left)); + addIcons(depths[leftIdx]); + items.add(Item.arrow(false)); + items.add(Item.text(you)); + addIcons(depths[mySeat]); + items.add(Item.arrow(false)); + items.add(Item.text(right)); + addIcons(depths[rightIdx]); + } + + widths = new float[items.size()]; + totalWidth = 0; + for (int i = 0; i < items.size(); i++) { + widths[i] = itemWidth(items.get(i)); + totalWidth += widths[i] + (i > 0 ? SEG_GAP : 0); + } + hasData = true; + } + + private void addIcons(int depth) { + if (depth > 0) items.add(Item.icons(depth)); + } + + private static float iconWidth() { + float h = FSkinImage.PACK.getHeight(); + float aspect = h > 0 ? FSkinImage.PACK.getWidth() / h : 18f / 25f; + return ICON_HEIGHT * aspect; + } + + private static float itemWidth(Item it) { + switch (it.kind) { + case TEXT: + return FONT.getBounds(it.text).width; + case ARROW: + return ARROW_WIDTH; + default: + float w = iconWidth(); + if (it.depth > 1) { + w += SEG_GAP + FONT.getBounds("x" + it.depth).width; + } + return w; + } + } + + @Override + public void draw(Graphics g) { + if (!hasData) return; + float h = getHeight(); + FSkinColor color = FSkinColor.get(FSkinColor.Colors.CLR_TEXT); + float x = Math.max(0, (getWidth() - totalWidth) / 2f); + for (int i = 0; i < items.size(); i++) { + Item it = items.get(i); + switch (it.kind) { + case TEXT: + g.drawText(it.text, FONT, color, x, 0, widths[i], h, false, Align.left, true); + break; + case ARROW: + drawArrow(g, color, x, h, it.arrowRight); + break; + default: + g.drawImage(FSkinImage.PACK, x, (h - ICON_HEIGHT) / 2f, iconWidth(), ICON_HEIGHT); + if (it.depth > 1) { + String cnt = "x" + it.depth; + g.drawText(cnt, FONT, color, x + iconWidth() + SEG_GAP, 0, + FONT.getBounds(cnt).width, h, false, Align.left, true); + } + break; + } + x += widths[i] + SEG_GAP; + } + } + + private static void drawArrow(Graphics g, FSkinColor color, float x, float h, boolean right) { + float midY = h / 2f; + if (right) { + g.fillTriangle(color, x, midY - ARROW_HALF_HEIGHT, x, midY + ARROW_HALF_HEIGHT, x + ARROW_WIDTH, midY); + } else { + g.fillTriangle(color, x + ARROW_WIDTH, midY - ARROW_HALF_HEIGHT, x + ARROW_WIDTH, midY + ARROW_HALF_HEIGHT, x, midY); + } + } + } + + static final class PicksDeckSectionPage extends DeckSectionPage { + PicksDeckSectionPage(CardManager cm, DeckSection section, ItemManagerConfig config) { + super(cm, section, config); + } + + void addPick(PaperCard card) { + cardManager.addItem(card, 1); + updateCaption(); + } + } + + private static final class NetworkDraftEditorConfig extends DeckEditorConfig { + @Override + public GameType getGameType() { + return GameType.Draft; + } + + @Override + public boolean isLimited() { return true; } + + @Override + public boolean isDraft() { return true; } + + @Override + public boolean hasInfiniteCardPool() { return false; } + + @Override + protected IDeckController getController() { + throw new UnsupportedOperationException("NetworkDraftEditorConfig uses a directly supplied controller"); + } + + @Override + protected DeckEditorPage[] getInitialPages() { + NetworkDraftPackPage packPage = new NetworkDraftPackPage(); + PicksDeckSectionPage mainPage = new PicksDeckSectionPage(new CardManager(false), DeckSection.Main, ItemManagerConfig.DRAFT_POOL); + PicksDeckSectionPage sidePage = new PicksDeckSectionPage(new CardManager(false), DeckSection.Sideboard, ItemManagerConfig.DRAFT_POOL); + return new DeckEditorPage[]{ packPage, mainPage, sidePage }; + } + } + + // Minimal in-memory controller for the picks deck + private static final class PicksDeckController implements IDeckController { + private Deck deck; + + PicksDeckController(Deck initial) { + this.deck = initial; + } + + @Override public void setEditor(FDeckEditor editor) { + if (deck != null) { editor.setDeck(deck); } + } + @Override public Deck getDeck() { return deck; } + @Override public void setDeck(Deck deck) { this.deck = deck; } + @Override public void newDeck() { this.deck = new Deck(); } + @Override public String getDeckDisplayName() { return ""; } + @Override public void notifyModelChanged() {} + @Override public void exitWithoutSaving() {} + } +} diff --git a/forge-gui-mobile/src/forge/screens/match/views/VChat.java b/forge-gui-mobile/src/forge/screens/match/views/VChat.java index f2092425280..b48e4d7536c 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VChat.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VChat.java @@ -10,6 +10,7 @@ import forge.localinstance.properties.ForgePreferences.FPref; import forge.menu.FDropDown; import forge.menu.FMenuTab; +import forge.menu.IUnreadIndicator; import forge.model.FModel; import forge.screens.online.ChatMessageBubble; import forge.screens.online.OnlineChatScreen; @@ -24,6 +25,7 @@ public class VChat extends FDropDown implements IOnlineChatInterface { private final FTextField txtSendMessage = add(new FTextField()); private IRemote gameClient; + private FDisplayObject owner; public VChat() { txtSendMessage.setGhostText(Forge.getLocalizer().getMessage("lblEnterMessageToSend")); @@ -46,6 +48,25 @@ private static OnlineChatScreen lobbyChat() { return (OnlineChatScreen) OnlineMenu.OnlineScreen.Chat.getScreen(); } + @Override + public void setMenuTab(FMenuTab menuTab) { + super.setMenuTab(menuTab); + this.owner = menuTab; + } + + public void setDropdownOwner(FDisplayObject owner0) { + this.owner = owner0; + } + + @Override + protected FDisplayObject getDropDownOwner() { + return owner; + } + + private IUnreadIndicator unreadIndicator() { + return owner instanceof IUnreadIndicator ind ? ind : null; + } + @Override public void setGameClient(IRemote gameClient0) { gameClient = gameClient0; @@ -57,9 +78,9 @@ public void addMessage(ChatMessage message) { if (isVisible()) { updateSizeAndPosition(); } else { - FMenuTab tab = getMenuTab(); - if (tab != null) { - tab.incrementUnread(); + IUnreadIndicator indicator = unreadIndicator(); + if (indicator != null) { + indicator.incrementUnread(); } } Gdx.graphics.requestRendering(); @@ -70,9 +91,9 @@ public void setVisible(boolean visible0) { boolean wasVisible = isVisible(); super.setVisible(visible0); if (visible0 && !wasVisible) { - FMenuTab tab = getMenuTab(); - if (tab != null) { - tab.clearUnread(); + IUnreadIndicator indicator = unreadIndicator(); + if (indicator != null) { + indicator.clearUnread(); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VLog.java b/forge-gui-mobile/src/forge/screens/match/views/VLog.java index 45c7a9a6e78..7932b366ae1 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VLog.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VLog.java @@ -39,11 +39,19 @@ private static FSkinColor getForeColor() { } protected final Supplier logSupplier; + private final boolean showAllEntries; protected FDisplayObject owner; public VLog(Supplier logSupplier) { + this(logSupplier, false); + } + + /** {@code showAllEntries} bypasses the match-log verbosity preference — the draft + * log holds only DRAFT entries, which the default verbosity would filter out. */ + public VLog(Supplier logSupplier, boolean showAllEntries) { this.logSupplier = logSupplier; + this.showAllEntries = showAllEntries; } @Override @@ -73,17 +81,28 @@ protected void drawBackground(Graphics g) { g.fillRect(getRowColor(), 0, 0, w, h); //can fill background with main row color since drop down will never be taller than number of rows } + /** Re-lay out the open dropdown so newly added entries appear without reopening it. */ + public void refresh() { + if (isVisible()) { + updateSizeAndPosition(); + } + } + @Override protected ScrollBounds updateAndGetPaneSize(float maxWidth, float maxVisibleHeight) { clear(); - GameLogVerbosity verbosity = GameLogVerbosity.fromString(FModel.getPreferences().getPref(FPref.DEV_LOG_ENTRY_TYPE)); List logEntrys; - if (verbosity == GameLogVerbosity.CUSTOM) { - logEntrys = logSupplier.get().getLogEntriesForTypes( - FModel.getPreferences().getCustomLogTypes()); + if (showAllEntries) { + logEntrys = logSupplier.get().getLogEntries(null); } else { - logEntrys = logSupplier.get().getLogEntriesForVerbosity(verbosity); + GameLogVerbosity verbosity = GameLogVerbosity.fromString(FModel.getPreferences().getPref(FPref.DEV_LOG_ENTRY_TYPE)); + if (verbosity == GameLogVerbosity.CUSTOM) { + logEntrys = logSupplier.get().getLogEntriesForTypes( + FModel.getPreferences().getCustomLogTypes()); + } else { + logEntrys = logSupplier.get().getLogEntriesForVerbosity(verbosity); + } } LogEntryDisplay logEntryDisplay; diff --git a/forge-gui-mobile/src/forge/screens/online/OnlineLobbyScreen.java b/forge-gui-mobile/src/forge/screens/online/OnlineLobbyScreen.java index 19c020f2a94..cdae113917f 100644 --- a/forge-gui-mobile/src/forge/screens/online/OnlineLobbyScreen.java +++ b/forge-gui-mobile/src/forge/screens/online/OnlineLobbyScreen.java @@ -6,29 +6,60 @@ import forge.Forge; import forge.assets.FSkinColor; import forge.assets.FSkinFont; +import forge.deck.Deck; +import forge.deck.DeckProxy; +import forge.deck.DeckType; +import forge.deck.FDeckChooser; +import forge.deck.FDeckEditor; +import forge.gamemodes.limited.BoosterDraft; +import forge.gamemodes.limited.LimitedPoolType; import forge.gamemodes.match.GameLobby; +import forge.gamemodes.match.LobbySlot; import forge.gamemodes.net.ChatMessage; +import forge.gamemodes.net.EventFormat; import forge.gamemodes.net.IOnlineChatInterface; import forge.gamemodes.net.IOnlineLobby; import forge.gamemodes.net.NetConnectUtil; +import forge.gamemodes.net.EventParticipant; +import forge.gamemodes.net.NetworkEvent; +import forge.gamemodes.net.NetworkEventView; import forge.gamemodes.net.OfflineLobby; import forge.gamemodes.net.client.FGameClient; +import forge.gamemodes.net.event.DraftPickEvent; import forge.gamemodes.net.server.FServerManager; +import forge.gamemodes.net.server.ServerGameLobby; import forge.gui.FThreads; +import forge.gui.interfaces.IDraftEventHandler; import forge.gui.interfaces.ILobbyView; +import forge.gui.util.SGuiChoose; import forge.gui.util.SOptionPane; +import forge.item.PaperCard; +import forge.itemmanager.ItemManagerConfig; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.skin.FSkinProp; +import forge.model.FModel; import forge.screens.LoadingOverlay; import forge.screens.constructed.LobbyScreen; +import forge.screens.constructed.PlayerPanel; +import forge.screens.limited.NetworkDraftingProcessScreen; import forge.screens.online.OnlineMenu.OnlineScreen; import forge.toolbox.FButton; +import forge.toolbox.FCheckBox; +import forge.toolbox.FComboBox; import forge.toolbox.FLabel; +import forge.toolbox.FOptionPane; +import forge.toolbox.FTextField; import forge.util.Utils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; -public class OnlineLobbyScreen extends LobbyScreen implements IOnlineLobby { +public class OnlineLobbyScreen extends LobbyScreen implements IOnlineLobby, IDraftEventHandler { private final FLabel lblTitle; private final FLabel lblWarning; @@ -36,6 +67,23 @@ public class OnlineLobbyScreen extends LobbyScreen implements IOnlineLobby { private final FLabel lblGuideLink; private final FButton btnHost; private final FButton btnJoin; + private final FComboBox cmbMode = new FComboBox<>(); + private final FButton btnSetUpEvent; + private final FButton btnStartEvent; + private final FButton btnStartMatch; + private final FButton btnDismissEvent; + private final FLabel lblEventPanel; + private final FCheckBox cbDeckConformance; + + private boolean isHost; + private boolean activeConformance = true; + // "Constructed" or "Limited" — matches the localised string + private String currentMode; + private String activeEventId; // non-null after receiveEventPool; used for deck filter + private NetworkEvent currentEvent; + private NetworkEventView lastEventView; + private boolean eventDrafted; // true after receiveEventPool marks draft done + private NetworkDraftingProcessScreen activeDraftScreen; public OnlineLobbyScreen() { super(null, OnlineMenu.getMenu(), new OfflineLobby()); @@ -69,6 +117,261 @@ public OnlineLobbyScreen() { btnJoin = new FButton(Forge.getLocalizer().getMessage("lblJoinGame")); btnJoin.setCommand(e -> activateJoin()); add(btnJoin); + + cmbMode.addItem(Forge.getLocalizer().getMessage("lblConstructed")); + cmbMode.addItem(Forge.getLocalizer().getMessage("lblLimited")); + currentMode = Forge.getLocalizer().getMessage("lblConstructed"); + cmbMode.setFont(FSkinFont.get(12)); + cmbMode.setChangedHandler(e -> onModeChanged()); + add(cmbMode); + + btnSetUpEvent = new FButton(Forge.getLocalizer().getMessage("lblNetworkNewEventButton")); + btnSetUpEvent.setCommand(e -> openSetUpEventDialog()); + add(btnSetUpEvent); + + btnStartEvent = new FButton(""); + btnStartEvent.setCommand(e -> startEvent()); + add(btnStartEvent); + + btnStartMatch = new FButton(Forge.getLocalizer().getMessage("lblNetworkStartMatch")); + btnStartMatch.setCommand(e -> startMatch()); + add(btnStartMatch); + + btnDismissEvent = new FButton("X"); + btnDismissEvent.setCommand(e -> onDismissEvent()); + add(btnDismissEvent); + + lblEventPanel = new FLabel.Builder().align(Align.left).font(FSkinFont.get(12)).build(); + add(lblEventPanel); + + cbDeckConformance = new FCheckBox(Forge.getLocalizer().getMessage("lblNetworkDeckFilter")); + cbDeckConformance.setFont(FSkinFont.get(12)); // match the event info panel + cbDeckConformance.setSelected(true); + cbDeckConformance.setCommand(e -> onConformanceChanged()); + add(cbDeckConformance); + } + + private boolean isLimitedMode() { + return Forge.getLocalizer().getMessage("lblLimited").equals(currentMode); + } + + private void onModeChanged() { + if (!isHost) return; + String selected = cmbMode.getSelectedItem(); + if (selected == null || selected.equals(currentMode)) return; + currentMode = selected; + + ServerGameLobby sgl = serverLobby(); + if (!isLimitedMode()) { + if (sgl != null) sgl.clearCurrentEvent(); + currentEvent = null; + activeEventId = null; + eventDrafted = false; + refreshEventPanel(); + updateDeckListFilter(); + } + // Broadcast the mode so clients mirror it (their combo is read-only) + if (sgl != null) sgl.setLimitedMode(isLimitedMode()); + + updateModeVisibility(); + updateActionButtons(); + revalidate(); + } + + private void onDismissEvent() { + ServerGameLobby sgl = serverLobby(); + if (sgl != null) sgl.clearCurrentEvent(); + currentEvent = null; + activeEventId = null; + broadcastEventSelection(); + refreshEventPanel(); + updateDeckListFilter(); + updateActionButtons(); + revalidate(); + } + + private void onConformanceChanged() { + activeConformance = cbDeckConformance.isSelected(); + updateDeckListFilter(); + broadcastEventSelection(); + } + + private void broadcastEventSelection() { + ServerGameLobby sgl = serverLobby(); + if (sgl != null) { + sgl.selectEventForMatch(activeEventId, activeConformance); + } + } + + private void updateModeVisibility() { + setVariantsVisible(!isLimitedMode()); + } + + private void updateActionButtons() { + if (!isHost) return; + boolean limited = isLimitedMode(); + if (limited) { + boolean hasEvent = currentEvent != null; + boolean hasPool = eventDrafted; + String startEventLabel = (hasEvent && currentEvent.getFormat() == EventFormat.SEALED) + ? Forge.getLocalizer().getMessage("lblNetworkGeneratePools") + : Forge.getLocalizer().getMessage("lblNetworkStartDraft"); + btnStartEvent.setText(startEventLabel); + btnStartEvent.setEnabled(hasEvent && !hasPool); + btnStartMatch.setEnabled(hasPool); + } + } + + private void openSetUpEventDialog() { + if (serverLobby() == null) return; + FThreads.invokeInBackgroundThread(() -> { + ServerGameLobby sgl = serverLobby(); + if (sgl == null) return; + + List pastEvents = scanPastEvents(); + if (!pastEvents.isEmpty()) { + String create = Forge.getLocalizer().getMessage("lblNetworkSetUpEventCreate"); + String loadPast = Forge.getLocalizer().getMessage("lblNetworkSetUpEventLoadPast"); + String setupChoice = SGuiChoose.oneOrNone( + Forge.getLocalizer().getMessage("lblNetworkSetUpEventPrompt"), + Arrays.asList(create, loadPast)); + if (setupChoice == null) return; + if (setupChoice.equals(loadPast)) { + loadPastEvent(pastEvents); + return; + } + } + + String lblDraft = Forge.getLocalizer().getMessage("lblDraft"); + String lblSealed = Forge.getLocalizer().getMessage("lblSealed"); + String chosen = SGuiChoose.oneOrNone( + Forge.getLocalizer().getMessage("lblNetworkChooseEventType"), + Arrays.asList(lblDraft, lblSealed)); + if (chosen == null) return; + + boolean isDraft = lblDraft.equals(chosen); + EventFormat format = isDraft ? EventFormat.BOOSTER_DRAFT : EventFormat.SEALED; + sgl.createEvent(format); + + LimitedPoolType poolType = SGuiChoose.oneOrNone( + Forge.getLocalizer().getMessage("lblNetworkChooseDraftFormat"), + Arrays.asList(LimitedPoolType.values(isDraft))); + if (poolType == null) return; + + // Building the draft fires set/format sub-dialogs + BoosterDraft draft = null; + if (isDraft) { + draft = BoosterDraft.createDraftForNetwork(poolType); + if (draft == null) return; + } + + NetworkEvent event = sgl.getCurrentEvent(); + if (event == null) return; + int timerSeconds = event.getPickTimerSeconds(); + int graceSeconds = event.getDisconnectGraceSeconds(); + + if (isDraft) { + String timersTitle = Forge.getLocalizer().getMessage("lblNetworkDraftTimersTitle"); + String pickStr = SOptionPane.showInputDialog( + Forge.getLocalizer().getMessage("lblNetworkPickTimerPrompt"), + timersTitle, null, String.valueOf(timerSeconds), null, true); + if (pickStr == null) return; + String graceStr = SOptionPane.showInputDialog( + Forge.getLocalizer().getMessage("lblNetworkGraceTimerPromptLine1") + " " + + Forge.getLocalizer().getMessage("lblNetworkGraceTimerPromptLine2"), + timersTitle, null, String.valueOf(graceSeconds), null, true); + if (graceStr == null) return; + finishConfigureEvent(sgl, poolType, draft, + parseSecondsOrDefault(pickStr, timerSeconds), + parseSecondsOrDefault(graceStr, graceSeconds)); + } else { + finishConfigureEvent(sgl, poolType, null, timerSeconds, graceSeconds); + } + }); + } + + private void finishConfigureEvent(ServerGameLobby sgl, LimitedPoolType poolType, + BoosterDraft draft, int timerSeconds, int graceSeconds) { + if (!sgl.configureEvent(poolType, draft, timerSeconds, graceSeconds)) return; + FThreads.invokeInEdtLater(() -> { + currentEvent = sgl.getCurrentEvent(); + eventDrafted = false; + refreshEventPanel(); + updateActionButtons(); + revalidate(); + }); + } + + private static int parseSecondsOrDefault(String text, int fallback) { + try { + int n = Integer.parseInt(text.trim()); + return n >= 0 ? n : fallback; + } catch (NumberFormatException e) { + return fallback; + } + } + + private List scanPastEvents() { + Map datesByEventId = new LinkedHashMap<>(); + for (Deck d : FModel.getDecks().getNetworkEventDecks()) { + String eventId = DeckProxy.getEventTag(d, "eventId"); + if (eventId != null) { + datesByEventId.putIfAbsent(eventId, DeckProxy.getEventTag(d, "eventDate")); + } + } + List ids = new ArrayList<>(datesByEventId.keySet()); + // eventDate is "yyyy-MM-dd HH:mm", so reverse lexical order lists newest first + ids.sort(Comparator.comparing( + (String id) -> datesByEventId.get(id) != null ? datesByEventId.get(id) : "", + Comparator.reverseOrder())); + List choices = new ArrayList<>(ids.size()); + for (String id : ids) { + choices.add(new NetworkEvent.EventChoice(id, NetworkEvent.getEventDisplayLabel(id))); + } + return choices; + } + + private void loadPastEvent(List pastEvents) { + NetworkEvent.EventChoice chosen = SGuiChoose.oneOrNone( + Forge.getLocalizer().getMessage("lblNetworkLoadPastEventPrompt"), pastEvents); + if (chosen == null) return; + FThreads.invokeInEdtLater(() -> { + activeEventId = chosen.id(); + activeConformance = true; + cbDeckConformance.setSelected(true); + broadcastEventSelection(); + updateDeckListFilter(); + refreshEventPanel(); + updateActionButtons(); + revalidate(); + }); + } + + private void startEvent() { + ServerGameLobby sgl = serverLobby(); + if (sgl == null || currentEvent == null) return; + LobbySlot unready = sgl.findFirstUnreadySlot(); + if (unready != null) { + FOptionPane.showMessageDialog( + Forge.getLocalizer().getMessage("lblPlayerIsNotReady", unready.getName())); + return; + } + if (currentEvent.getFormat() == EventFormat.BOOSTER_DRAFT) { + FThreads.invokeInBackgroundThread(() -> { + ServerGameLobby.DraftStartResult result = sgl.startDraftEvent(); + if (result == null) { + FThreads.invokeInEdtLater(() -> + FOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblNetworkFailedDraft"))); + } + }); + } else { + FThreads.invokeInBackgroundThread(sgl::startSealedEvent); + } + } + + private ServerGameLobby serverLobby() { + GameLobby lobby = getLobby(); + return lobby instanceof ServerGameLobby sgl ? sgl : null; } private static GameLobby gameLobby; @@ -96,17 +399,126 @@ public static void closeClient() { fGameClient = null; } + @Override + public IDraftEventHandler getDraftHandler() { + return this; + } + + @Override + public void draftPackArrived(int seatIndex, List pack, + int packNumber, int pickNumber, int timerDurationSeconds) { + // Host routes picks directly; client sends via network + Consumer pickSender; + if (getLobby() instanceof ServerGameLobby sgl) { + pickSender = ev -> sgl.handleDraftPick(ev, -1); + } else { + pickSender = ev -> { + if (getfGameClient() != null) { + getfGameClient().send(ev); + } + }; + } + final Consumer finalPickSender = pickSender; + + // This runs on a Netty thread; activeDraftScreen and the event fields are EDT-owned + FThreads.invokeInEdtNowOrLater(() -> { + if (activeDraftScreen == null) { + // Host has the full NetworkEvent; a client only ever has the broadcast view + List participants = currentEvent != null + ? currentEvent.getParticipants() + : (lastEventView != null ? lastEventView.getParticipants() : List.of()); + activeDraftScreen = new NetworkDraftingProcessScreen( + seatIndex, participants, + finalPickSender, + () -> { + activeDraftScreen = null; + closeConn(""); + }); + Forge.openScreen(activeDraftScreen); + } + activeDraftScreen.onPackArrived(pack, packNumber, pickNumber, timerDurationSeconds); + }); + } + + @Override + public void draftSeatPicked(int seatIndex, int[] seatQueueDepths) { + FThreads.invokeInEdtNowOrLater(() -> { + if (activeDraftScreen != null) { + activeDraftScreen.onSeatPicked(seatIndex, seatQueueDepths); + } + }); + } + + @Override + public void draftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack) { + FThreads.invokeInEdtNowOrLater(() -> { + if (activeDraftScreen != null) { + activeDraftScreen.onAutoPicked(seatIndex, card, packNumber, pickInPack); + } + }); + } + + @Override + public void receiveEventPool(String eventId, Deck pool) { + FThreads.invokeInEdtNowOrLater(() -> { + if (currentEvent != null && DeckProxy.getEventTag(pool, "eventId") == null) { + NetworkEvent.setEventTags(pool, currentEvent); + } + FModel.getDecks().getNetworkEventDecks().add(pool); + + activeEventId = eventId; + activeConformance = true; + if (isHost) { + broadcastEventSelection(); + } + updateDeckListFilter(); + + boolean draftCompletion = activeDraftScreen != null; + if (draftCompletion) { + activeDraftScreen.onDraftCompleted(); + activeDraftScreen = null; + Forge.back(); + } + eventDrafted = true; + currentEvent = null; + updateActionButtons(); + revalidate(); + + Forge.openScreen(new FDeckEditor(FDeckEditor.EditorConfigNetworkEventPool, pool)); + }); + } + @Override public void closeConn(String msg) { - clearGameLobby(); - Forge.back(); + if (getfGameClient() != null) { + getfGameClient().setDraftHandler(null); + } + if (FServerManager.getInstance() != null) { + FServerManager.getInstance().setDraftHandler(null); + } + // A dropped connection invokes this on a Netty thread; the lobby/draft-screen state + // is EDT-owned, so reset it and tear down the screen on the EDT. + FThreads.invokeInEdtNowOrLater(() -> { + NetworkDraftingProcessScreen draftScreen = activeDraftScreen; + activeDraftScreen = null; + currentEvent = null; + activeEventId = null; + activeConformance = true; + eventDrafted = false; + lastEventView = null; + clearGameLobby(); + if (draftScreen != null) { + draftScreen.onDraftCompleted(); // connection gone — close silently, like a finished draft + } + Forge.back(); + }); if (msg.length() > 0) { FThreads.invokeInBackgroundThread(() -> { final boolean callBackAlwaysTrue = SOptionPane.showOptionDialog(msg, Forge.getLocalizer().getMessage("lblError"), FSkinProp.ICO_WARNING, List.of(Forge.getLocalizer().getMessage("lblOK")), 1) == 0; if (callBackAlwaysTrue) { //to activate online menu popup when player press play online - if(FServerManager.getInstance() != null) + if (FServerManager.getInstance() != null) FServerManager.getInstance().stopServer(); - if(getfGameClient() != null) + if (getfGameClient() != null) closeClient(); } }); @@ -124,6 +536,46 @@ public void setClient(FGameClient client) { fGameClient = client; } + @Override + public void update(boolean fullUpdate) { + super.update(fullUpdate); + // The base constructor calls update() before our fields initialize; skip until ready + if (cmbMode == null) { + return; + } + if (!isHost && getLobby() != null && getLobby().getData() != null) { + GameLobby.GameLobbyData data = getLobby().getData(); + + // The combo is read-only on a client; mirror the host's declared mode + String hostMode = Forge.getLocalizer().getMessage(data.isLimitedMode() ? "lblLimited" : "lblConstructed"); + boolean modeChanged = !hostMode.equals(currentMode); + if (modeChanged) { + currentMode = hostMode; + cmbMode.setSelectedItem(currentMode); + } + + NetworkEventView view = data.getEventView(); + boolean viewChanged = view != lastEventView; + if (viewChanged) { + lastEventView = view; + activeEventId = view != null ? view.getEventId() : null; + } + + boolean newConformance = data.isActiveConformance(); + if (newConformance != activeConformance) { + activeConformance = newConformance; + updateDeckListFilter(); + } + + if (modeChanged || viewChanged) { + updateModeVisibility(); + updateDeckListFilter(); + refreshEventPanel(); + revalidate(); + } + } + } + @Override public void onActivate() { if (getGameLobby() == null) { @@ -133,11 +585,29 @@ public void onActivate() { } } + @Override + public void setStartButtonAvailability() { + // In Limited the action-button row replaces btnStart; keep it hidden so the base + // host-visibility rule (called from update()) doesn't draw it over those buttons. + if (isHost && isLimitedMode()) { + btnStart.setVisible(false); + return; + } + super.setStartButtonAvailability(); + } + @Override protected void doLayoutAboveBtnStart(float startY, float width, float height) { if (getGameLobby() == null) { btnStart.setVisible(false); setLobbyControlsVisible(false); + cmbMode.setVisible(false); + btnSetUpEvent.setVisible(false); + btnStartEvent.setVisible(false); + btnStartMatch.setVisible(false); + btnDismissEvent.setVisible(false); + lblEventPanel.setVisible(false); + cbDeckConformance.setVisible(false); float padding = Utils.scale(10); float y = startY + height * 0.15f; @@ -179,13 +649,145 @@ protected void doLayoutAboveBtnStart(float startY, float width, float height) { btnHost.setVisible(false); btnJoin.setVisible(false); setLobbyControlsVisible(true); - btnStart.setVisible(true); + // Variants are hidden in Limited mode (host and client alike) + if (isLimitedMode()) { + setVariantsVisible(false); + } + + float padding = Utils.scale(10); - super.doLayoutAboveBtnStart(startY, width, height); + boolean limited = isLimitedMode(); + float fieldH = FTextField.getDefaultHeight(FSkinFont.get(12)); + float comboW = Utils.AVG_FINGER_WIDTH * 3; + // The mode combo shows for host and client alike; read-only (disabled) for a + // client, whose selection is synced from the host in update(). + cmbMode.setVisible(true); + cmbMode.setEnabled(isHost); + cmbMode.setBounds(padding, startY + padding, comboW, fieldH); + // Give the combo its own row in both modes so the player list starts at the + // same height; in Limited the hidden variants row leaves that slot blank + float superStartY = startY + padding + fieldH; + + if (isHost && limited) { + btnStart.setVisible(false); + updateActionButtons(); + } else { + btnSetUpEvent.setVisible(false); + btnStartEvent.setVisible(false); + btnStartMatch.setVisible(false); + if (isHost) { + btnStart.setVisible(true); // Constructed host; clients have no start button + } + } + + // Event info and the deck-filter toggle sit in a band at the bottom of the + // lobby, just above the action buttons; shrink the player list to fit above it. + boolean hasEvent = currentEvent != null || lastEventView != null || activeEventId != null; + boolean showConformance = isLimitedMode() && (currentEvent != null || activeEventId != null); + float panelH = hasEvent ? lblEventPanel.getAutoSizeBounds().height : 0; + float checkH = showConformance ? Utils.AVG_FINGER_HEIGHT * 0.75f : 0; + float bottomBandH = (hasEvent ? panelH + padding : 0) + (showConformance ? checkH + padding : 0); + + // In Limited the action buttons sit centred in btnStart's (taller) band, so + // anchor the event band just above that row rather than to the band's top — + // otherwise the centring slack leaves a gap below the conformance checkbox. + float rowH = Utils.AVG_FINGER_HEIGHT; + float actionRowTop = btnStart.getTop() + (btnStart.getHeight() - rowH) / 2f; + float bandBottom = (isHost && limited) ? actionRowTop : height; + float by = bandBottom - bottomBandH; + + super.doLayoutAboveBtnStart(superStartY, width, by); + + if (hasEvent) { + float dismissW = Utils.AVG_FINGER_WIDTH; + boolean showDismiss = isHost && currentEvent != null; + float panelW = showDismiss ? width - dismissW - padding : width; + lblEventPanel.setBounds(0, by, panelW, panelH); + lblEventPanel.setVisible(true); + if (showDismiss) { + btnDismissEvent.setBounds(panelW + padding, by, dismissW, panelH); + btnDismissEvent.setVisible(true); + } else { + btnDismissEvent.setVisible(false); + } + by += panelH + padding; + } else { + lblEventPanel.setVisible(false); + btnDismissEvent.setVisible(false); + } + + if (showConformance) { + cbDeckConformance.setBounds(0, by, width, checkH); + cbDeckConformance.setEnabled(isHost); + cbDeckConformance.setVisible(true); + } else { + cbDeckConformance.setVisible(false); + } + + if (isHost && limited) { + // The three Limited action buttons replace btnStart, laid across its band + float btnW = width / 3 - padding; + btnSetUpEvent.setBounds(0, actionRowTop, btnW, rowH); + btnStartEvent.setBounds(btnW + padding, actionRowTop, btnW, rowH); + btnStartMatch.setBounds((btnW + padding) * 2, actionRowTop, btnW, rowH); + btnSetUpEvent.setVisible(true); + btnStartEvent.setVisible(true); + btnStartMatch.setVisible(true); + } + } + } + + void refreshEventPanel() { + if (currentEvent == null && lastEventView == null && activeEventId == null) { + lblEventPanel.setText(""); + revalidate(); + return; + } + NetworkEvent.EventPanelText text = NetworkEvent.computeEventPanelText( + isHost, activeEventId, currentEvent, lastEventView); + StringBuilder sb = new StringBuilder(); + if (!text.formatText().isEmpty()) sb.append(text.formatText()).append('\n'); + if (!text.productText().isEmpty()) sb.append(text.productText()).append('\n'); + if (!text.timerText().isEmpty()) sb.append(text.timerText()).append('\n'); + if (!text.dateText().isEmpty()) sb.append(text.dateText()).append('\n'); + if (!text.statusText().isEmpty()) sb.append(text.statusText()); + String result = sb.toString(); + if (result.endsWith("\n")) result = result.substring(0, result.length() - 1); + lblEventPanel.setText(result); + revalidate(); + } + + private void updateDeckListFilter() { + FModel.getDecks().reloadNetworkEventDecks(); + String filterEventId = activeEventId != null ? activeEventId + : (currentEvent != null ? currentEvent.getEventId() : null); + List pool = DeckProxy.getAllNetworkEventDecks(); + if (filterEventId != null && activeConformance) { + pool.removeIf(dp -> dp.getDeck() == null + || !filterEventId.equals(DeckProxy.getEventTag(dp.getDeck(), "eventId"))); + } + for (PlayerPanel panel : getPlayerPanels()) { + FDeckChooser chooser = panel.getDeckChooser(); + if (chooser.getSelectedDeckType() != DeckType.NET_EVENT_DECK) { + chooser.setSelectedDeckType(DeckType.NET_EVENT_DECK); + } + DeckProxy prev = chooser.getLstDecks().getSelectedItem(); + String prevName = (prev != null && prev.getDeck() != null) ? prev.getDeck().getName() : null; + chooser.getLstDecks().setPool(pool); + chooser.getLstDecks().setup(ItemManagerConfig.NET_EVENT_DECKS); + if (prevName != null) { + for (DeckProxy dp : pool) { + if (dp.getDeck() != null && prevName.equals(dp.getDeck().getName())) { + chooser.getLstDecks().setSelectedItem(dp); + break; + } + } + } } } private void activateHost() { + isHost = true; setGameLobby(getLobby()); revalidate(); NetConnectUtil.ensurePlayerName(); @@ -203,6 +805,7 @@ private void activateHost() { } private void activateJoin() { + isHost = false; setGameLobby(getLobby()); revalidate(); FThreads.invokeInBackgroundThread(() -> { diff --git a/forge-gui-mobile/src/forge/toolbox/DraftTimerRope.java b/forge-gui-mobile/src/forge/toolbox/DraftTimerRope.java new file mode 100644 index 00000000000..d5b129113ce --- /dev/null +++ b/forge-gui-mobile/src/forge/toolbox/DraftTimerRope.java @@ -0,0 +1,101 @@ +package forge.toolbox; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; + +import forge.Graphics; +import forge.assets.FSkinColor; +import forge.assets.FSkinColor.Colors; + +/** + * Thin horizontal progress bar drained right-to-left across the pick countdown. + * Colour stages: CLR_ACTIVE above 15s, yellow at 15s, red at 5s. Border is a + * brightened shade of CLR_ACTIVE so it stays constant through transitions. + */ +public final class DraftTimerRope extends FDisplayObject { + + private static final float BORDER_BRIGHTEN = 2.0f; + private static final Color FALLBACK_ACTIVE = new Color(0x3A6EA5FF); + + private long startMillis; + private int durationSeconds; + private boolean wasContinuousRendering; + + public void start(int seconds) { + if (seconds <= 0) { + stop(); + return; + } + if (!isRunning()) { + wasContinuousRendering = Gdx.graphics.isContinuousRendering(); + Gdx.graphics.setContinuousRendering(true); + } + durationSeconds = seconds; + startMillis = System.currentTimeMillis(); + } + + public void stop() { + if (!isRunning()) { + return; // never started, or already stopped — don't clobber the render mode + } + startMillis = 0; + Gdx.graphics.setContinuousRendering(wasContinuousRendering); + } + + /** Returns remaining seconds, rounded up; 0 when stopped or expired. */ + public int getRemainingSeconds() { + return (int) Math.ceil(remainingExact()); + } + + private boolean isRunning() { + return startMillis > 0 && durationSeconds > 0; + } + + private double remainingExact() { + double elapsed = (System.currentTimeMillis() - startMillis) / 1000.0; + return Math.max(0.0, durationSeconds - elapsed); + } + + private Color colorForStage() { + if (!isRunning()) return activeColor(); + int whole = (int) Math.ceil(remainingExact()); + if (whole <= 5) return Color.RED; + if (whole <= 15) return Color.YELLOW; + return activeColor(); + } + + private static Color activeColor() { + FSkinColor c = FSkinColor.get(Colors.CLR_ACTIVE); + return (c != null) ? c.getColor() : FALLBACK_ACTIVE; + } + + private static Color brighten(Color c, float factor) { + return new Color( + Math.min(1f, c.r * factor), + Math.min(1f, c.g * factor), + Math.min(1f, c.b * factor), + c.a); + } + + @Override + public void draw(Graphics g) { + float w = getWidth(); + float h = getHeight(); + if (w <= 0 || h <= 0) return; + + Color active = activeColor(); + Color border = brighten(active, BORDER_BRIGHTEN); + + g.drawRect(1f, border, 0, 0, w, h); + + if (isRunning()) { + float innerW = w - 2; + float fillWidth = (float) (innerW * remainingExact() / durationSeconds); + if (fillWidth > 0) { + // Right-anchored: rope drains left as time runs out + float x = 1 + (innerW - fillWidth); + g.fillRect(colorForStage(), x, 1, fillWidth, h - 2); + } + } + } +} diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index cbf27b4d1a1..b08b7cced34 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -772,7 +772,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 +lblNetEventDecks=Online Draft/Sealed Decks lblNetArchivePauperDecks=Net Archive Pauper Decks #VSubmenuTutorial lblTutorial=Tutorial @@ -2637,6 +2637,8 @@ lblSelectingFilter=Select Filter... lblLoadingKeywords=Loading keywords... #LoadSealedScreen.java lblYouMustSelectExistingSealedPool=You must select an existing deck or build a deck from a new sealed pool. +lblEventDeckEditOnly=Event decks can be opened in the editor only. +lblNoOpponentsForEventDeck=No AI opponents #LoadGauntletScreen.java lblYouMustCreateAndSelectGauntlet=You must create and select a gauntlet. lblSelectGauntletDeck=Select Deck for Gauntlet diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IDraftEventHandler.java b/forge-gui/src/main/java/forge/gui/interfaces/IDraftEventHandler.java index e99d9ecde0e..f912ea9b0be 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IDraftEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IDraftEventHandler.java @@ -17,7 +17,7 @@ void draftPackArrived(int seatIndex, List pack, void draftAutoPicked(int seatIndex, PaperCard card, int packNumber, int pickInPack); void receiveEventPool(String eventId, Deck pool); - /** Returns true if {@code event} was a draft event and was dispatched. */ + /** Returns true if {@code event} was a recognized draft/event-pool event and was dispatched. */ default boolean dispatch(NetEvent event) { if (event instanceof DraftPackArrivedEvent e) { draftPackArrived(e.getSeatIndex(), e.getPack(),