From b6a8bcb164bc7d53b9eebc5f4873b7a955e9f3a8 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:18:34 +0930 Subject: [PATCH 01/21] Refactor yield state to per-PCH YieldController with unified wire envelope Each PlayerControllerHuman now composes a YieldController instance that owns all yield state for its player: legacy autoPassUntilEndOfTurn, phase markers, stack-yield, per-card/ability auto-yield, trigger decisions, and skip-phase prefs. The 8 yield-related "ugly netplay fields" on PCH are deleted (7 migrated, 1 deferred); every yield accessor collapses to a one-line delegation, removing the isRemoteGuiProxy() fork from each method. Five fragmented per-method ProtocolMethod entries collapse into two unified entries (sendYieldUpdate CLIENT, applyYieldUpdate SERVER) carrying a sealed YieldUpdate envelope with seven record cases. Game- start chatter drops from N messages (one per saved auto-yield) to a single SeedFromClient(snapshot) message; same path serves reconnection. New MVP features ungated: - Phase markers via right-click (desktop) / long-press (mobile) on phase indicators. Auto-passes priority until the marked phase, then clears. - "Yield to entire stack" stack-item context menu entry. Stack-yield resolves the entire stack including post-cancel additions; only ESC clears it. Master interrupt behavior preserved: MagicStack.add() opponent-spell cancel routes through PlayerController.autoPassCancel() -> YieldController.cancelYield() (clears legacy + marker; stack-yield deliberately survives). PhaseHandler turn-boundary cancel and GameAction game-start cleanup unchanged. Carve-out from PR #9643. Deferred features (smart suggestions, interrupt prefs, auto-pass-no-actions, speed settings, host gating) land in the YieldRework follow-up PR rebased onto this foundation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 46 +++- .../forge/screens/match/views/VField.java | 1 + .../forge/screens/match/views/VPrompt.java | 30 +++ .../forge/screens/match/views/VStack.java | 54 ++-- .../forge/toolbox/special/PhaseIndicator.java | 42 +++- .../forge/toolbox/special/PhaseLabel.java | 156 +++++++++--- .../forge/screens/match/MatchController.java | 44 +++- .../screens/match/views/VPhaseIndicator.java | 100 ++++++-- .../screens/match/views/VPlayerPanel.java | 1 + .../src/forge/screens/match/views/VStack.java | 26 +- forge-gui/res/languages/en-US.properties | 3 + .../gamemodes/match/AbstractGuiGame.java | 69 +++--- .../forge/gamemodes/match/HostedMatch.java | 2 +- .../gamemodes/match/YieldController.java | 232 ++++++++++++++++++ .../forge/gamemodes/match/YieldMarker.java | 35 +++ .../gamemodes/match/YieldStateSnapshot.java | 23 ++ .../forge/gamemodes/match/YieldUpdate.java | 40 +++ .../forge/gamemodes/net/NetworkGuiGame.java | 51 +++- .../forge/gamemodes/net/ProtocolMethod.java | 9 +- .../gamemodes/net/client/FGameClient.java | 1 - .../net/client/NetGameController.java | 87 ++++++- .../net/server/RemoteClientGuiGame.java | 6 + .../java/forge/gui/interfaces/IGuiGame.java | 10 +- .../forge/interfaces/IGameController.java | 16 ++ .../forge/player/PlayerControllerHuman.java | 92 ++++--- 25 files changed, 994 insertions(+), 182 deletions(-) create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldController.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 1a68783baae..69c46dceeaa 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -67,7 +67,9 @@ import forge.game.spellability.StackItemView; import forge.game.zone.ZoneType; import forge.util.IHasForgeLog; +import forge.gamemodes.match.YieldMarker; import forge.gamemodes.net.NetworkGuiGame; +import forge.interfaces.IGameController; import forge.gui.FNetOverlay; import forge.gui.FThreads; import forge.gui.GuiBase; @@ -211,6 +213,15 @@ public boolean isCurrentScreen() { return Singletons.getControl().getCurrentScreen() == this.screen; } + /** Returns the CMatchUI controlling the currently active match screen, or null. */ + public static CMatchUI getActive() { + FScreen current = Singletons.getControl().getCurrentScreen(); + if (current == null || !current.isMatchScreen()) { + return null; + } + return current.getController() instanceof CMatchUI cm ? cm : null; + } + private boolean isInGame() { return getGameView() != null; } @@ -235,6 +246,39 @@ protected void afterDeltaApplied() { refreshAllViews(); } + @Override + public void refreshYieldUi(final PlayerView player) { + FThreads.invokeInEdtNowOrLater(() -> { + // Marker is rendered only on the local player's view of the targeted phase indicator. + PlayerView local = getCurrentPlayer(); + if (local == null || !local.equals(player)) { + return; + } + for (final VField f : getFieldViews()) { + PhaseIndicator pi = f.getPhaseIndicator(); + if (pi == null) { + continue; + } + for (PhaseLabel l : pi.allLabels()) { + l.setYieldMarked(false); + } + } + IGameController controller = getGameController(local); + YieldMarker marker = controller != null ? controller.getYieldController().getMarker() : null; + if (marker == null) { + return; + } + VField markedField = getFieldViewFor(marker.getPhaseOwner()); + if (markedField == null) { + return; + } + PhaseLabel target = markedField.getPhaseIndicator().getLabelFor(marker.getPhase()); + if (target != null) { + target.setYieldMarked(true); + } + }); + } + private void refreshAllViews() { if (sortedPlayers != null) { FThreads.invokeInEdtNowOrLater(() -> { @@ -1314,7 +1358,7 @@ private void actuateMatchPreferences() { } } - seedSkipPhaseCache(); + seedYieldStateOnHost(); } @Override diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java index b463b915aed..7fbc4c98d22 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java @@ -99,6 +99,7 @@ public VField(final CMatchUI matchUI, final EDocID id0, final PlayerView p, fina this.docID = id0; this.player = p; + phaseIndicator.setOwner(p); if (p != null) { tab.setText(Localizer.getInstance().getMessage("lblPlayField", p.getName())); } else { tab.setText(Localizer.getInstance().getMessage("lblNoPlayerForEDocID", docID.toString())); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 2fb0f440829..af4686b5ea2 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -30,13 +30,19 @@ import javax.swing.SwingConstants; import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; import forge.gui.framework.IVDoc; +import forge.interfaces.IGameController; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; +import forge.player.PlayerControllerHuman; +import forge.screens.match.CMatchUI; import forge.screens.match.controllers.CPrompt; import forge.toolbox.FButton; import forge.toolbox.FHtmlViewer; @@ -75,6 +81,30 @@ public void setCardView(final CardView card) { @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + CMatchUI ui = CMatchUI.getActive(); + if (ui != null) { + PlayerView local = ui.getCurrentPlayer(); + IGameController ctrl = local != null ? ui.getGameController(local) : null; + if (ctrl != null) { + YieldController yc = ctrl.getYieldController(); + boolean hasMarker = yc.getMarker() != null; + boolean hasLegacy = yc.isAutoPassUntilEndOfTurn(); + boolean hasStackYield = yc.isStackYieldActive(); + if (hasMarker) { + ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } + if (hasLegacy && ctrl instanceof PlayerControllerHuman pch) { + pch.autoPassCancel(); + } + if (hasStackYield) { + ctrl.sendYieldUpdate(new YieldUpdate.SetStackYield(local, false)); + } + if (hasMarker || hasLegacy || hasStackYield) { + ui.refreshYieldUi(local); + return; + } + } + } if (btnCancel.isEnabled()) { if (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals("End Turn")) { btnCancel.doClick(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java index 6467ac8c23b..bce717ac755 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java @@ -18,6 +18,7 @@ package forge.screens.match.views; import java.awt.Color; +import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; @@ -27,6 +28,7 @@ import java.awt.image.BufferedImage; import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.ScrollPaneConstants; import javax.swing.SwingUtilities; @@ -35,7 +37,9 @@ import forge.CachedCardImage; import forge.game.GameView; import forge.game.card.CardView.CardStateView; +import forge.game.player.PlayerView; import forge.game.spellability.StackItemView; +import forge.gamemodes.match.YieldUpdate; import forge.gui.card.CardDetailUtil; import forge.gui.card.CardDetailUtil.DetailColors; import forge.gui.framework.DragCell; @@ -231,22 +235,30 @@ public void mouseClicked(final MouseEvent e) { } }); - if (item.isAbility()) { - addMouseListener(new FMouseAdapter() { - @Override - public void onLeftClick(final MouseEvent e) { - onClick(e); - } - @Override - public void onRightClick(final MouseEvent e) { - onClick(e); + addMouseListener(new FMouseAdapter() { + @Override + public void onLeftClick(final MouseEvent e) { + onClick(e); + } + @Override + public void onRightClick(final MouseEvent e) { + onClick(e); + } + private void onClick(final MouseEvent e) { + abilityMenu.setStackInstance(item); + boolean hasVisibleItem = false; + for (Component c : abilityMenu.getComponents()) { + if (c.isVisible()) { + hasVisibleItem = true; + break; + } } - private void onClick(final MouseEvent e) { - abilityMenu.setStackInstance(item); - abilityMenu.show(e.getComponent(), e.getX(), e.getY()); + if (!hasVisibleItem) { + return; } - }); - } + abilityMenu.show(e.getComponent(), e.getX(), e.getY()); + } + }); // TODO: A hacky workaround is currently used to make the game not leak the color information for Morph cards. final CardStateView curState = item.getSourceCard().getCurrentState(); @@ -284,6 +296,7 @@ private final class AbilityMenu extends JPopupMenu { private final JCheckBoxMenuItem jmiAutoYield; private final JCheckBoxMenuItem jmiAlwaysYes; private final JCheckBoxMenuItem jmiAlwaysNo; + private final JMenuItem jmiYieldToEntireStack; private StackItemView item; private Integer triggerID = 0; @@ -324,13 +337,24 @@ public AbilityMenu(){ } }); add(jmiAlwaysNo); + + jmiYieldToEntireStack = new JMenuItem(Localizer.getInstance().getMessage("lblYieldToEntireStack")); + jmiYieldToEntireStack.addActionListener(arg0 -> { + final PlayerView local = controller.getMatchUI().getCurrentPlayer(); + if (local == null) return; + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.SetStackYield(local, true)); + controller.getMatchUI().getGameController().passPriority(); + }); + add(jmiYieldToEntireStack); } public void setStackInstance(final StackItemView item0) { item = item0; triggerID = item.getSourceTrigger(); - jmiAutoYield.setSelected(controller.getMatchUI().getGameController().shouldAutoYield(item.getKey())); + jmiAutoYield.setVisible(item.isAbility()); + jmiAutoYield.setSelected(item.isAbility() + && controller.getMatchUI().getGameController().shouldAutoYield(item.getKey())); if (item.isOptionalTrigger() && controller.getMatchUI().isLocalPlayer(item.getActivatingPlayer())) { jmiAlwaysYes.setSelected(controller.getMatchUI().getGameController().shouldAlwaysAcceptTrigger(triggerID)); diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java index f723d27260a..bf74b666769 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java @@ -1,8 +1,12 @@ package forge.toolbox.special; +import java.util.Arrays; +import java.util.List; + import javax.swing.JPanel; import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; import forge.util.Localizer; import net.miginfocom.swing.MigLayout; @@ -14,18 +18,18 @@ public class PhaseIndicator extends JPanel { private static final long serialVersionUID = -863730022835609252L; // Phase labels - private PhaseLabel lblUpkeep = new PhaseLabel("UP"); - private PhaseLabel lblDraw = new PhaseLabel("DR"); - private PhaseLabel lblMain1 = new PhaseLabel("M1"); - private PhaseLabel lblBeginCombat = new PhaseLabel("BC"); - private PhaseLabel lblDeclareAttackers = new PhaseLabel("DA"); - private PhaseLabel lblDeclareBlockers = new PhaseLabel("DB"); - private PhaseLabel lblFirstStrike = new PhaseLabel("FS"); - private PhaseLabel lblCombatDamage = new PhaseLabel("CD"); - private PhaseLabel lblEndCombat = new PhaseLabel("EC"); - private PhaseLabel lblMain2 = new PhaseLabel("M2"); - private PhaseLabel lblEndTurn = new PhaseLabel("ET"); - private PhaseLabel lblCleanup = new PhaseLabel("CL"); + private PhaseLabel lblUpkeep = new PhaseLabel("UP", PhaseType.UPKEEP); + private PhaseLabel lblDraw = new PhaseLabel("DR", PhaseType.DRAW); + private PhaseLabel lblMain1 = new PhaseLabel("M1", PhaseType.MAIN1); + private PhaseLabel lblBeginCombat = new PhaseLabel("BC", PhaseType.COMBAT_BEGIN); + private PhaseLabel lblDeclareAttackers = new PhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS); + private PhaseLabel lblDeclareBlockers = new PhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS); + private PhaseLabel lblFirstStrike = new PhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE); + private PhaseLabel lblCombatDamage = new PhaseLabel("CD", PhaseType.COMBAT_DAMAGE); + private PhaseLabel lblEndCombat = new PhaseLabel("EC", PhaseType.COMBAT_END); + private PhaseLabel lblMain2 = new PhaseLabel("M2", PhaseType.MAIN2); + private PhaseLabel lblEndTurn = new PhaseLabel("ET", PhaseType.END_OF_TURN); + private PhaseLabel lblCleanup = new PhaseLabel("CL", PhaseType.CLEANUP); public PhaseIndicator() { @@ -110,6 +114,20 @@ public PhaseLabel getLabelFor(final PhaseType s) { } } + /** Push the per-VField player binding to every label so right-click can route to it. */ + public void setOwner(final PlayerView player) { + for (PhaseLabel l : allLabels()) { + l.setPhaseOwner(player); + } + } + + public List allLabels() { + return Arrays.asList( + lblUpkeep, lblDraw, lblMain1, lblBeginCombat, lblDeclareAttackers, + lblDeclareBlockers, lblFirstStrike, lblCombatDamage, lblEndCombat, + lblMain2, lblEndTurn, lblCleanup); + } + /** * Resets all phase buttons to "inactive", so highlight won't be drawn on * them. "Enabled" state remains the same. diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java index 28f44c3de31..79d908201e7 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java @@ -1,13 +1,23 @@ package forge.toolbox.special; +import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import javax.swing.JLabel; import javax.swing.SwingConstants; - +import javax.swing.SwingUtilities; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldUpdate; +import forge.interfaces.IGameController; +import forge.screens.match.CMatchUI; import forge.toolbox.FSkin; /** @@ -17,9 +27,15 @@ */ @SuppressWarnings("serial") public class PhaseLabel extends JLabel { + /** Tint used when a yield marker is targeted at this (player, phase) cell. */ + private static final Color YIELD_MARKER_COLOR = new Color(0xFFA528); + + private final PhaseType phaseType; + private PlayerView phaseOwner; private boolean enabled = true; private boolean active = false; private boolean hover = false; + private boolean yieldMarked = false; private Runnable onToggled; @@ -28,17 +44,22 @@ public class PhaseLabel extends JLabel { * PhaseLabel has "skip" and "active" states, meaning * "this phase is (not) skipped" and "this is the current phase". * - * @param txt - *   Label text + * @param txt   Label text + * @param phaseType0   The PhaseType this label represents (may be null for header/spacer labels) */ - public PhaseLabel(final String txt) { + public PhaseLabel(final String txt, final PhaseType phaseType0) { super(txt); + this.phaseType = phaseType0; this.setHorizontalTextPosition(SwingConstants.CENTER); this.setHorizontalAlignment(SwingConstants.CENTER); this.addMouseListener(new MouseAdapter() { @Override public void mousePressed(final MouseEvent e) { + if (SwingUtilities.isRightMouseButton(e)) { + handleRightClick(); + return; + } PhaseLabel.this.enabled = !PhaseLabel.this.enabled; if (PhaseLabel.this.onToggled != null) { PhaseLabel.this.onToggled.run(); @@ -59,11 +80,67 @@ public void mouseExited(final MouseEvent e) { }); } + public PhaseType getPhaseType() { + return phaseType; + } + + public PlayerView getPhaseOwner() { + return phaseOwner; + } + + public void setPhaseOwner(final PlayerView v) { + this.phaseOwner = v; + } + + public boolean isYieldMarked() { + return yieldMarked; + } + + public void setYieldMarked(final boolean b) { + if (this.yieldMarked != b) { + this.yieldMarked = b; + repaintOnlyThisLabel(); + } + } + + private void handleRightClick() { + if (phaseOwner == null || phaseType == null) { + return; + } + CMatchUI ui = CMatchUI.getActive(); + if (ui == null) { + return; + } + PlayerView local = ui.getCurrentPlayer(); + if (local == null) { + return; + } + IGameController controller = ui.getGameController(local); + if (controller == null) { + return; + } + YieldMarker existing = controller.getYieldController().getMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phaseType == existing.getPhase(); + if (clickedSameLabel) { + controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } else { + // Setting a marker implies we want to stop here — un-skip the cell + // so the marker can fire (skip-phase pref + marker would skip past). + this.enabled = true; + repaintOnlyThisLabel(); + controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phaseType)); + // Pass current priority so the marker takes effect immediately. + controller.selectButtonOk(); + } + ui.refreshYieldUi(local); + } + /** * Determines whether play pauses at this phase or not. - * - * @param b - *   boolean, true if play pauses + * + * @param b   boolean, true if play pauses */ @Override public void setEnabled(final boolean b) { @@ -86,9 +163,8 @@ public void setOnToggled(final Runnable r) { /** * Makes this phase the current phase (or not). - * - * @param b - *   boolean, true if phase is current + * + * @param b   boolean, true if phase is current */ public void setActive(final boolean b) { this.active = b; @@ -97,7 +173,7 @@ public void setActive(final boolean b) { /** * Determines if this phase is the current phase (or not). - * + * * @return boolean */ public boolean getActive() { @@ -110,37 +186,61 @@ public void repaintOnlyThisLabel() { repaint(0, 0, d.width, d.height); } - /* - * (non-Javadoc) - * - * @see javax.swing.JComponent#paintComponent(java.awt.Graphics) - */ @Override public void paintComponent(final Graphics g) { final int w = this.getWidth(); final int h = this.getHeight(); - FSkin.SkinColor c; - // Set color according to skip or active or hover state of label + // Precedence: hover > yieldMarked > active/enabled combinations. if (this.hover) { - c = FSkin.getColor(FSkin.Colors.CLR_HOVER); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_HOVER)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); + } + else if (this.yieldMarked) { + g.setColor(YIELD_MARKER_COLOR); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (this.active && this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_ENABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_ENABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (!this.active && this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_ENABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_ENABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else if (this.active && !this.enabled) { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_DISABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_ACTIVE_DISABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } else { - c = FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_DISABLED); + FSkin.setGraphicsColor(g, FSkin.getColor(FSkin.Colors.CLR_PHASE_INACTIVE_DISABLED)); + g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); } - // Center vertically and horizontally. Show border if active. - FSkin.setGraphicsColor(g, c); - g.fillRoundRect(1, 1, w - 2, h - 2, 5, 5); - super.paintComponent(g); + if (this.yieldMarked) { + drawChevron(g, w, h); + } else { + super.paintComponent(g); + } + } + + private void drawChevron(final Graphics g, final int w, final int h) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.BLACK); + // Two back-to-back triangles — total width is `size`, total height is `size`. + int size = Math.max(6, (int) (h * 0.55)); + int x = (w - size) / 2; + int y = (h - size) / 2; + int[] xs1 = {x, x + size / 2, x}; + int[] ys1 = {y, y + size / 2, y + size}; + g2.fillPolygon(xs1, ys1, 3); + int[] xs2 = {x + size / 2, x + size, x + size / 2}; + int[] ys2 = {y, y + size / 2, y + size}; + g2.fillPolygon(xs2, ys2, 3); + } finally { + g2.dispose(); + } } -} \ No newline at end of file +} diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 68f98d71958..2ccecd4fa23 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -38,8 +38,10 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldMarker; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.match.HostedMatch; +import forge.interfaces.IGameController; import forge.gui.FThreads; import forge.gui.GuiBase; import forge.gui.util.SGuiChoose; @@ -574,7 +576,7 @@ private static void actuateMatchPreferences() { } } - instance.seedSkipPhaseCache(); + instance.seedYieldStateOnHost(); } public static void writeMatchPreferences() { @@ -695,6 +697,46 @@ public boolean isUiSetToSkipPhase(final PlayerView playerTurn, final PhaseType p return !view.stopAtPhase(playerTurn, phase); } + @Override + public void refreshYieldUi(final PlayerView player) { + FThreads.invokeInEdtNowOrLater(() -> { + if (view == null) { + return; + } + // Marker only rendered for the local player's view. + PlayerView local = getCurrentPlayer(); + if (local == null || !local.equals(player)) { + return; + } + for (final VPlayerPanel panel : view.getPlayerPanelsList()) { + VPhaseIndicator pi = panel.getPhaseIndicator(); + if (pi == null) { + continue; + } + for (VPhaseIndicator.PhaseLabel l : pi.allLabels()) { + l.setYieldMarked(false); + } + } + IGameController controller = getGameController(local); + YieldMarker marker = controller != null ? controller.getYieldController().getMarker() : null; + if (marker == null) { + return; + } + VPlayerPanel markedPanel = view.getPlayerPanel(marker.getPhaseOwner()); + if (markedPanel == null) { + return; + } + VPhaseIndicator markedPi = markedPanel.getPhaseIndicator(); + if (markedPi == null) { + return; + } + VPhaseIndicator.PhaseLabel target = markedPi.getLabel(marker.getPhase()); + if (target != null) { + target.setYieldMarked(true); + } + }); + } + public static HostedMatch hostMatch() { hostedMatch = new HostedMatch(); return hostedMatch; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java index 711a62f6833..80b8032494f 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java @@ -12,6 +12,11 @@ import forge.assets.FSkinColor.Colors; import forge.assets.FSkinFont; import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldUpdate; +import forge.interfaces.IGameController; +import forge.screens.match.MatchController; import forge.toolbox.FContainer; import forge.toolbox.FDisplayObject; import forge.util.TextBounds; @@ -22,8 +27,11 @@ public class VPhaseIndicator extends FContainer { public static final float PADDING_X = Utils.scale(1); public static final float PADDING_Y = Utils.scale(2); + private static final Color YIELD_MARKER_COLOR = new Color(0xFFA528FF); + private final Map phaseLabels = new HashMap<>(); private FSkinFont font; + private PlayerView owner; public VPhaseIndicator() { addPhaseLabel("UP", PhaseType.UPKEEP); @@ -48,6 +56,14 @@ public PhaseLabel getLabel(PhaseType phaseType) { return phaseLabels.get(phaseType); } + public Iterable allLabels() { + return phaseLabels.values(); + } + + public void setOwner(PlayerView player) { + this.owner = player; + } + public void resetPhaseButtons() { for (PhaseLabel lbl : phaseLabels.values()) { lbl.setActive(false); @@ -86,7 +102,7 @@ protected void doLayout(float width, float height) { float x = 0; float w = width / phaseLabels.size(); float h = height; - + for (FDisplayObject lbl : getChildren()) { lbl.setBounds(x, 0, w, h); x += w; @@ -110,6 +126,7 @@ public class PhaseLabel extends FDisplayObject { private final PhaseType phaseType; private boolean stopAtPhase = false; private boolean active = false; + private boolean yieldMarked = false; private Runnable onToggled; public PhaseLabel(String caption0, PhaseType phaseType0) { @@ -135,6 +152,13 @@ public void setStopAtPhase(boolean stopAtPhase0) { stopAtPhase = stopAtPhase0; } + public boolean isYieldMarked() { + return yieldMarked; + } + public void setYieldMarked(boolean v) { + this.yieldMarked = v; + } + /** Fires after the user toggles this label by tapping. */ public void setOnToggled(Runnable r) { onToggled = r; @@ -147,6 +171,37 @@ public boolean tap(float x, float y, int count) { return true; } + @Override + public boolean longPress(float x, float y) { + PlayerView phaseOwner = VPhaseIndicator.this.owner; + if (phaseOwner == null) { + return false; + } + PlayerView local = MatchController.instance.getCurrentPlayer(); + if (local == null) { + return false; + } + IGameController ctrl = MatchController.instance.getGameController(local); + if (ctrl == null) { + return false; + } + YieldMarker existing = ctrl.getYieldController().getMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phaseType == existing.getPhase(); + if (clickedSameLabel) { + ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } else { + // Setting a marker implies we want to stop here — un-skip the cell so the marker can fire. + stopAtPhase = true; + ctrl.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phaseType)); + // Pass current priority so the marker takes effect immediately. + ctrl.selectButtonOk(); + } + MatchController.instance.refreshYieldUi(local); + return true; + } + @Override public void draw(final Graphics g) { float x = PADDING_X; @@ -154,21 +209,36 @@ public void draw(final Graphics g) { float h = getHeight(); //determine back color according to skip or active state of label - FSkinColor backColor; - if (active && stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_ENABLED); - } - else if (!active && stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_ENABLED); - } - else if (active && !stopAtPhase) { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_DISABLED); - } - else { - backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_DISABLED); + if (yieldMarked) { + g.fillRect(YIELD_MARKER_COLOR, x, 0, w, h); + drawChevron(g, x, w, h); + // Skip the caption when marked — chevron replaces the phase abbreviation. + } else { + FSkinColor backColor; + if (active && stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_ENABLED); + } + else if (!active && stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_ENABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_ENABLED); + } + else if (active && !stopAtPhase) { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_ACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_ACTIVE_DISABLED); + } + else { + backColor = Forge.isMobileAdventureMode ? FSkinColor.get(Colors.ADV_CLR_PHASE_INACTIVE_DISABLED) : FSkinColor.get(Colors.CLR_PHASE_INACTIVE_DISABLED); + } + g.fillRect(isHovered() ? backColor.brighter() : backColor, x, 0, w, h); + g.drawText(caption, isHovered() && font.canIncrease() ? font.increase() : font, Color.BLACK, x, 0, w, h, false, Align.center, true); } - g.fillRect(isHovered() ? backColor.brighter() : backColor, x, 0, w, h); - g.drawText(caption, isHovered() && font.canIncrease() ? font.increase() : font, Color.BLACK, x, 0, w, h, false, Align.center, true); + } + + private void drawChevron(final Graphics g, float x, float w, float h) { + // Two back-to-back triangles centered in the cell, mirroring desktop. + float size = Math.max(Utils.scale(6f), h * 0.55f); + float cx = x + (w - size) / 2f; + float cy = (h - size) / 2f; + g.fillTriangle(Color.BLACK, cx, cy, cx + size / 2f, cy + size / 2f, cx, cy + size); + g.fillTriangle(Color.BLACK, cx + size / 2f, cy, cx + size, cy + size / 2f, cx + size / 2f, cy + size); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java index 25735826693..5bf0a89626f 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java @@ -94,6 +94,7 @@ private static FSkinColor getDeliriumHighlight() { public VPlayerPanel(PlayerView player0, boolean showHand, int playerCount) { player = player0; phaseIndicator = add(new VPhaseIndicator()); + phaseIndicator.setOwner(player); if (playerCount > 2) { forMultiPlayer = true; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VStack.java b/forge-gui-mobile/src/forge/screens/match/views/VStack.java index db97402fbf5..4027c275e37 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VStack.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VStack.java @@ -13,6 +13,7 @@ import forge.Graphics; import forge.assets.FSkinColor; import forge.assets.FSkinFont; +import forge.assets.FSkinImage; import forge.assets.TextRenderer; import forge.card.CardRenderer; import forge.card.CardRenderer.CardStackPosition; @@ -22,6 +23,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.StackItemView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldUpdate; import forge.gui.card.CardDetailUtil; import forge.gui.card.CardDetailUtil.DetailColors; import forge.interfaces.IGameController; @@ -284,10 +286,10 @@ public boolean tap(float x, float y, int count) { final IGameController controller = MatchController.instance.getGameController(); final PlayerView player = MatchController.instance.getCurrentPlayer(); if (player != null) { //don't show menu if tapping on art - if (stackInstance.isAbility()) { - FPopupMenu menu = new FPopupMenu() { - @Override - protected void buildMenu() { + FPopupMenu menu = new FPopupMenu() { + @Override + protected void buildMenu() { + if (stackInstance.isAbility()) { final String key = stackInstance.getKey(); final boolean autoYield = controller.shouldAutoYield(key); addItem(new FCheckBoxMenuItem(Forge.getLocalizer().getMessage("cbpAutoYieldMode"), autoYield, @@ -323,13 +325,19 @@ protected void buildMenu() { } })); } - addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); } - }; + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToEntireStack"), + Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, + e -> { + controller.sendYieldUpdate(new YieldUpdate.SetStackYield(player, true)); + controller.passPriority(); + })); + addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); + } + }; - menu.show(this, x, y); - return true; - } + menu.show(this, x, y); + return true; } CardZoom.show(stackInstance.getSourceCard()); return true; diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index d7660d94a7e..bc3cf2a3d4d 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1591,6 +1591,9 @@ lblCloseGameSpectator=This will close this game and you will not be able to resu lblCloseGame=Close Game? lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. +lblYieldingUntilPhaseFmt=Yielding until %s.\nYou may cancel this yield to take an action. +lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. +lblYieldToEntireStack=Yield to entire stack lblStopWatching=Stop Watching lblEnterNumberBetweenMinAndMax=Enter a number between {0} and {1}: lblEnterNumberGreaterThanOrEqualsToMin=Enter a number greater than or equal to {0}: diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index c916ed99514..eec41d6f9b0 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -169,7 +169,6 @@ public void setGameController(PlayerView player, final IGameController gameContr gameControllers.put(player, originalGameControllers.get(player)); } else { gameControllers.remove(player); - autoPassUntilEndOfTurn.remove(player); final PlayerView currentPlayer = getCurrentPlayer(); if (player.equals(currentPlayer)) { // set current player to a value known to be legal @@ -418,36 +417,6 @@ public String getConcedeCaption() { // Auto-yield and other input-related code - private final Set autoPassUntilEndOfTurn = Sets.newHashSet(); - - /** - * Automatically pass priority until reaching the Cleanup phase of the - * current turn. - */ - @Override - public final void autoPassUntilEndOfTurn(final PlayerView player) { - autoPassUntilEndOfTurn.add(player); - updateAutoPassPrompt(); - } - - @Override - public final void autoPassCancel(final PlayerView player) { - if (!autoPassUntilEndOfTurn.remove(player)) { - return; - } - - //prevent prompt getting stuck on yielding message while actually waiting for next input opportunity - final PlayerView playerView = getCurrentPlayer(); - showPromptMessage(playerView, ""); - updateButtons(playerView, false, false, false); - awaitNextInput(); - } - - @Override - public final boolean mayAutoPass(final PlayerView player) { - return autoPassUntilEndOfTurn.contains(player); - } - private Timer awaitNextInputTimer; private TimerTask awaitNextInputTask; @@ -581,12 +550,29 @@ public final void cancelAwaitNextInput() { @Override public final void updateAutoPassPrompt() { - if (!autoPassUntilEndOfTurn.isEmpty()) { - //allow user to cancel auto-pass - cancelAwaitNextInput(); //don't overwrite prompt with awaiting opponent - showPromptMessage(getCurrentPlayer(), Localizer.getInstance().getMessage("lblYieldingUntilEndOfTurn")); - updateButtons(getCurrentPlayer(), false, true, false); + YieldController yielding = null; + for (IGameController c : gameControllers.values()) { + YieldController yc = c.getYieldController(); + if (yc != null && yc.shouldAutoYield()) { + yielding = yc; + break; + } + } + if (yielding == null) { + return; } + Localizer loc = Localizer.getInstance(); + final String message; + if (yielding.getMarker() != null) { + message = loc.getMessage("lblYieldingUntilPhaseFmt", yielding.getMarker().getPhase().nameForUi); + } else if (yielding.isStackYieldActive()) { + message = loc.getMessage("lblYieldingUntilStackClears"); + } else { + message = loc.getMessage("lblYieldingUntilEndOfTurn"); + } + cancelAwaitNextInput(); + showPromptMessage(getCurrentPlayer(), message); + updateButtons(getCurrentPlayer(), false, true, false); } // End auto-yield/input code @@ -816,4 +802,15 @@ public void updateDependencies() { public void applyDelta(DeltaPacket packet) { // No-op for local games - network implementation is in NetworkGuiGame } + + @Override + public void applyYieldUpdate(YieldUpdate update) { + // Default: route to current player's controller. Network impl in NetworkGuiGame. + PlayerView player = getCurrentPlayer(); + if (player == null) return; + IGameController controller = getGameController(player); + if (controller != null) { + controller.applyYieldUpdate(update); + } + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index adaf086b601..63013c6780e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -427,7 +427,7 @@ public Void visit(final UiEventBlockerAssigned event) { humanController.getGui().updateSingleCard(event.blocker()); final PlayerView p = humanController.getPlayer().getView(); if (event.attackerBeingBlocked() != null && event.attackerBeingBlocked().getController().equals(p)) { - humanController.getGui().autoPassCancel(p); + humanController.autoPassCancel(); } } return null; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java new file mode 100644 index 00000000000..332d6f2be79 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -0,0 +1,232 @@ +package forge.gamemodes.match; + +import forge.game.GameView; +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.player.AutoYieldStore; +import forge.player.PlayerControllerHuman; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Per-PlayerControllerHuman yield state holder. Owns markers, stack-yield, + * autopass-until-end-of-turn, per-card/ability auto-yield, trigger + * decisions, and skip-phase prefs. + * + * Host-side instances are authoritative (full Game access). Client-side + * instances on NetGameController are caches populated by SERVER-mode wire + * messages. + */ +public class YieldController { + + private final PlayerControllerHuman owner; + + private boolean autoPassUntilEndOfTurn; + + private YieldMarker marker; + /** Priority has passed through any non-target phase since marker activation. */ + private boolean hasLeftMarker; + /** Marker was set while priority was already at its target; require a full cycle to fire. */ + private boolean activationOnMarker; + + /** Survives opponent spells; auto-clears only when stack empties, NOT on cancelYield. */ + private boolean stackYield; + + private final Set cardYields = new HashSet<>(); + private final Set abilityYields = new HashSet<>(); + private boolean autoYieldsDisabled; + + private final Map triggerDecisions = new HashMap<>(); + + private final Map> skipPhases = new HashMap<>(); + + public YieldController(PlayerControllerHuman owner) { + this.owner = owner; + } + + public boolean isAutoPassUntilEndOfTurn() { + return autoPassUntilEndOfTurn; + } + + public void setAutoPassUntilEndOfTurn(boolean active) { + this.autoPassUntilEndOfTurn = active; + } + + public boolean shouldAutoYield() { + if (autoPassUntilEndOfTurn) return true; + + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; + + if (stackYield) { + if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) { + return true; + } + stackYield = false; + } + + if (marker == null || gv == null) return false; + + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType currentPhase = gv.getPhase(); + + boolean inMarkerOwnerTurn = turnPlayer != null && turnPlayer.equals(marker.getPhaseOwner()); + boolean atTarget = inMarkerOwnerTurn && currentPhase == marker.getPhase(); + boolean pastTarget = inMarkerOwnerTurn && currentPhase != null + && marker.getPhase() != null && currentPhase.isAfter(marker.getPhase()); + + boolean shouldFire = hasLeftMarker + && (atTarget || (!activationOnMarker && pastTarget)); + + if (shouldFire) { + clearMarker(); + notifyMarkerCleared(); + return false; + } + if (!atTarget && !hasLeftMarker) { + hasLeftMarker = true; + } + return true; + } + + private void notifyMarkerCleared() { + if (owner == null || owner.getGui() == null) return; + PlayerView player = owner.getLocalPlayerView(); + if (player == null) return; + owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(player)); + } + + /** + * Engine-driven cancel matching master's autoPassCancel semantics: clears + * legacy autopass-until-end-of-turn only. Markers and stack-yield survive — + * markers can target phases on future turns (must outlive turn-boundary + * cancellation), and stack-yield must resolve the entire stack including + * post-cancel additions. User-initiated ESC clears all three explicitly. + */ + public void cancelYield() { + autoPassUntilEndOfTurn = false; + } + + public void setCardAutoYield(String key, boolean active, boolean abilityScope) { + Set bucket = abilityScope ? abilityYields : cardYields; + if (active) bucket.add(key); + else bucket.remove(key); + } + + public boolean shouldAutoYieldKey(String key, boolean abilityScope) { + if (autoYieldsDisabled) return false; + Set bucket = abilityScope ? abilityYields : cardYields; + return bucket.contains(key); + } + + public Set getCardYields() { + return Collections.unmodifiableSet(cardYields); + } + + public Set getAbilityYields() { + return Collections.unmodifiableSet(abilityYields); + } + + public void clearAutoYields() { + cardYields.clear(); + abilityYields.clear(); + triggerDecisions.clear(); + } + + public boolean isAutoYieldsDisabled() { + return autoYieldsDisabled; + } + + public void setAutoYieldsDisabled(boolean disabled) { + this.autoYieldsDisabled = disabled; + } + + public void setTriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) { + if (decision == null || decision == AutoYieldStore.TriggerDecision.ASK) { + triggerDecisions.remove(trigId); + } else { + triggerDecisions.put(trigId, decision); + } + } + + public AutoYieldStore.TriggerDecision getTriggerDecision(int trigId) { + return triggerDecisions.getOrDefault(trigId, AutoYieldStore.TriggerDecision.ASK); + } + + public Map getTriggerDecisions() { + return Collections.unmodifiableMap(triggerDecisions); + } + + public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { + if (turnPlayer == null || phase == null) return; + EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); + if (skip) set.add(phase); + else set.remove(phase); + } + + public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { + EnumSet set = skipPhases.get(turnPlayer); + return set != null && set.contains(phase); + } + + public Map> getSkipPhases() { + return Collections.unmodifiableMap(skipPhases); + } + + public void setMarker(PlayerView phaseOwner, PhaseType phase) { + autoPassUntilEndOfTurn = false; + if (phaseOwner == null || phase == null) { + clearMarker(); + return; + } + marker = new YieldMarker(phaseOwner, phase); + boolean atMarkerNow = isPriorityAt(marker); + hasLeftMarker = !atMarkerNow; + activationOnMarker = atMarkerNow; + } + + public void clearMarker() { + marker = null; + hasLeftMarker = false; + activationOnMarker = false; + } + + public YieldMarker getMarker() { return marker; } + + public void setStackYield(boolean active) { + if (active) autoPassUntilEndOfTurn = false; + this.stackYield = active; + } + + public boolean isStackYieldActive() { return stackYield; } + + private boolean isPriorityAt(YieldMarker m) { + if (m == null || owner == null || owner.getGui() == null) return false; + GameView gv = owner.getGui().getGameView(); + if (gv == null) return false; + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType phase = gv.getPhase(); + return turnPlayer != null + && turnPlayer.equals(m.getPhaseOwner()) + && phase == m.getPhase(); + } + + /** Atomic seed of client-persistent state at game start or reconnection. */ + public void applyClientSeed(YieldStateSnapshot snap) { + cardYields.clear(); + cardYields.addAll(snap.cardYields()); + abilityYields.clear(); + abilityYields.addAll(snap.abilityYields()); + triggerDecisions.clear(); + triggerDecisions.putAll(snap.triggerDecisions()); + autoYieldsDisabled = snap.autoYieldsDisabled(); + skipPhases.clear(); + for (Map.Entry> e : snap.skipPhases().entrySet()) { + skipPhases.put(e.getKey(), EnumSet.copyOf(e.getValue())); + } + } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java new file mode 100644 index 00000000000..4e0821d6443 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldMarker.java @@ -0,0 +1,35 @@ +package forge.gamemodes.match; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; + +import java.io.Serializable; +import java.util.Objects; + +/** Immutable (phaseOwner, phase) target of a yield-until-phase intent. */ +public final class YieldMarker implements Serializable { + private static final long serialVersionUID = 1L; + + private final PlayerView phaseOwner; + private final PhaseType phase; + + public YieldMarker(PlayerView phaseOwner, PhaseType phase) { + this.phaseOwner = Objects.requireNonNull(phaseOwner); + this.phase = Objects.requireNonNull(phase); + } + + public PlayerView getPhaseOwner() { return phaseOwner; } + public PhaseType getPhase() { return phase; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof YieldMarker)) return false; + YieldMarker other = (YieldMarker) o; + return phaseOwner.equals(other.phaseOwner) && phase == other.phase; + } + @Override + public int hashCode() { return Objects.hash(phaseOwner, phase); } + @Override + public String toString() { return phaseOwner + "@" + phase; } +} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java new file mode 100644 index 00000000000..0e4ef02e470 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldStateSnapshot.java @@ -0,0 +1,23 @@ +package forge.gamemodes.match; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.player.AutoYieldStore; + +import java.io.Serializable; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * Atomic snapshot of a client's persistent yield state, transmitted at + * game start (and on reconnection) so the host's PCH proxy is fully + * seeded in one wire message instead of N. + */ +public record YieldStateSnapshot( + Set cardYields, + Set abilityYields, + Map triggerDecisions, + boolean autoYieldsDisabled, + Map> skipPhases +) implements Serializable {} diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java new file mode 100644 index 00000000000..a602d9c212b --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -0,0 +1,40 @@ +package forge.gamemodes.match; + +import forge.game.phase.PhaseType; +import forge.game.player.PlayerView; +import forge.player.AutoYieldStore; + +import java.io.Serializable; + +/** + * Unified envelope for all yield-related sync between client and host. + * One sealed type with seven cases replaces eight per-method ProtocolMethod + * entries. Both directions (CLIENT->HOST, HOST->CLIENT) ride this envelope. + * + * Receiver dispatches via exhaustive switch in PlayerControllerHuman + * (host-side) and NetworkGuiGame (client-side). + */ +public sealed interface YieldUpdate extends Serializable + permits YieldUpdate.SetMarker, + YieldUpdate.ClearMarker, + YieldUpdate.SetStackYield, + YieldUpdate.SetTriggerDecision, + YieldUpdate.SetCardAutoYield, + YieldUpdate.SetSkipPhase, + YieldUpdate.SeedFromClient { + + record SetMarker(PlayerView phaseOwner, PhaseType phase) implements YieldUpdate {} + + record ClearMarker(PlayerView player) implements YieldUpdate {} + + record SetStackYield(PlayerView player, boolean active) implements YieldUpdate {} + + record SetTriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) implements YieldUpdate {} + + record SetCardAutoYield(String cardKey, boolean active, boolean abilityScope) implements YieldUpdate {} + + record SetSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} + + /** Atomic snapshot of client's persistent yield state. Sent by client at game start and reconnection only - not host->client. */ + record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index e0549bcddd7..2f885c2aa43 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -9,7 +9,11 @@ import forge.game.player.PlayerView; import forge.game.zone.ZoneType; import forge.gamemodes.match.AbstractGuiGame; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.client.NetGameController; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; import forge.interfaces.IGameController; import forge.trackable.Tracker; import forge.trackable.TrackableCollection; @@ -603,6 +607,22 @@ private void logChecksumDetails(GameView gameView, DeltaPacket packet) { NetworkChecksumUtil.computeChecksumBreakdown(gameView.getTurn(), phaseOrdinal, gameView)); } + @Override + public void applyYieldUpdate(YieldUpdate update) { + PlayerView player = getCurrentPlayer(); + IGameController controller = (player != null) ? getGameController(player) : null; + if (controller != null) { + controller.applyYieldUpdate(update); + } + // Repaint UI if marker/stack-yield state changed. + if (player != null + && (update instanceof YieldUpdate.SetMarker + || update instanceof YieldUpdate.ClearMarker + || update instanceof YieldUpdate.SetStackYield)) { + refreshYieldUi(player); + } + } + protected final void pushSkipPhaseToControllers(final PlayerView player, final PhaseType phase) { // Mind-slave AND-combines master+controlled rows, so a master toggle invalidates dependents for (final PlayerView p : getGameView().getPlayers()) { @@ -616,18 +636,35 @@ protected final void pushSkipPhaseToControllers(final PlayerView player, final P } } - protected final void seedSkipPhaseCache() { + /** + * Replace the host's persistent yield state for each controlled player + * in one atomic message: auto-yields and trigger-disabled flag from the + * AutoYieldStore, skip-phase prefs from PhaseLabel state. Per-key edits + * during play flow as individual YieldUpdate deltas. + */ + protected final void seedYieldStateOnHost() { + Map> skipPhases = collectSkipPhases(); for (final IGameController c : getOriginalGameControllers()) { if (c instanceof NetGameController nc) { - for (PlayerView p : getGameView().getPlayers()) { - for (PhaseType ph : PhaseType.values()) { - if (isUiSetToSkipPhase(p, ph)) { - nc.setUiShouldSkipPhase(p, ph, Boolean.TRUE); - } - } + nc.seedYieldStateOnHost(skipPhases); + } + } + } + + private Map> collectSkipPhases() { + Map> out = new HashMap<>(); + for (PlayerView p : getGameView().getPlayers()) { + EnumSet set = EnumSet.noneOf(PhaseType.class); + for (PhaseType ph : PhaseType.values()) { + if (isUiSetToSkipPhase(p, ph)) { + set.add(ph); } } + if (!set.isEmpty()) { + out.put(p, set); + } } + return out; } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index 2aac7bd9f63..3ac2d4e09f9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -5,11 +5,11 @@ import forge.game.GameView; import forge.game.card.CardView; -import forge.game.phase.PhaseType; import forge.game.player.DelayedReveal; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldUpdate; import forge.gui.GuiBase; import forge.gui.interfaces.IGuiGame; import forge.interfaces.IGameController; @@ -74,6 +74,7 @@ public enum ProtocolMethod implements IHasForgeLog { showWaitingTimer (Mode.SERVER, Void.TYPE, PlayerView.class, String.class), setHighlighted (Mode.SERVER, Void.TYPE, GameEntityView.class, Boolean.TYPE), applyDelta (Mode.SERVER, Void.TYPE, DeltaPacket.class), + applyYieldUpdate (Mode.SERVER, Void.TYPE, YieldUpdate.class), // Client -> Server // Note: these should all return void, to avoid awkward situations in @@ -94,11 +95,7 @@ public enum ProtocolMethod implements IHasForgeLog { alphaStrike (Mode.CLIENT, Void.TYPE), reorderHand (Mode.CLIENT, Void.TYPE, CardView.class, Integer.TYPE), requestResync (Mode.CLIENT, Void.TYPE), - setShouldAutoYield (Mode.CLIENT, Void.TYPE, String.class, Boolean.TYPE, Boolean.TYPE), - setShouldAlwaysAcceptTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), - setShouldAlwaysDeclineTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), - setShouldAlwaysAskTrigger (Mode.CLIENT, Void.TYPE, Integer.TYPE), - setUiShouldSkipPhase (Mode.CLIENT, Void.TYPE, PlayerView.class, PhaseType.class, Boolean.TYPE); + sendYieldUpdate (Mode.CLIENT, Void.TYPE, YieldUpdate.class); private enum Mode { SERVER(IGuiGame.class), 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 0f20a48669b..0275a49fc74 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 @@ -155,7 +155,6 @@ void setGameControllers(final Iterable myPlayers) { for (final PlayerView p : myPlayers) { NetGameController controller = new NetGameController(this); clientGui.setOriginalGameController(p, controller); - controller.replayActiveYields(); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index cabab229155..0c4e0b46e12 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -6,6 +6,9 @@ import forge.game.player.actions.PlayerAction; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldStateSnapshot; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.GameProtocolSender; import forge.gamemodes.net.ProtocolMethod; import forge.interfaces.IDevModeCheats; @@ -18,7 +21,12 @@ import forge.player.PersistentYieldStore; import forge.util.ITriggerEvent; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; public class NetGameController implements IGameController { @@ -26,10 +34,18 @@ public class NetGameController implements IGameController { private final AutoYieldStore yieldStore = new AutoYieldStore(); + /** Local cache mirroring host-side state for client-side UI rendering. */ + private final YieldController yieldController = new YieldController(null); + public NetGameController(final IToServer server) { sender = new GameProtocolSender(server); } + @Override + public YieldController getYieldController() { + return yieldController; + } + private void send(final ProtocolMethod method, final Object... args) { sender.send(method, args); } @@ -170,7 +186,9 @@ public void setShouldAutoYield(final String key, final boolean autoYield, final } else { yieldStore.setYield(activeTier(), storageKey, autoYield); } - send(ProtocolMethod.setShouldAutoYield, storageKey, autoYield, isAbilityScope); + yieldController.setCardAutoYield(storageKey, autoYield, isAbilityScope); + send(ProtocolMethod.sendYieldUpdate, + new YieldUpdate.SetCardAutoYield(storageKey, autoYield, isAbilityScope)); } @Override @@ -204,31 +222,82 @@ public boolean shouldAlwaysDeclineTrigger(final int trigger) { @Override public void setShouldAlwaysAcceptTrigger(final int trigger) { yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); - send(ProtocolMethod.setShouldAlwaysAcceptTrigger, trigger); + yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); + send(ProtocolMethod.sendYieldUpdate, + new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); } @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); - send(ProtocolMethod.setShouldAlwaysDeclineTrigger, trigger); + yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); + send(ProtocolMethod.sendYieldUpdate, + new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE)); } @Override public void setShouldAlwaysAskTrigger(final int trigger) { yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); - send(ProtocolMethod.setShouldAlwaysAskTrigger, trigger); - } - - public void replayActiveYields() { + yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); + send(ProtocolMethod.sendYieldUpdate, + new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK)); + } + + /** + * Build a YieldStateSnapshot from the local persistent yield state plus the + * GUI-loaded skip-phase prefs and ship it to the host in one wire message. + */ + public void seedYieldStateOnHost(Map> skipPhases) { + Set cardYields = new HashSet<>(); + Set abilityYields = new HashSet<>(); boolean abilityScope = activeModeIsAbilityScope(); for (String key : getAutoYields()) { - send(ProtocolMethod.setShouldAutoYield, key, Boolean.TRUE, abilityScope); + if (abilityScope) abilityYields.add(key); + else cardYields.add(key); } + // Trigger decisions are per-game; deltas flow during play. + Map triggers = new HashMap<>(); + YieldStateSnapshot snap = new YieldStateSnapshot( + cardYields, abilityYields, triggers, yieldStore.isDisabled(), + skipPhases == null ? new HashMap<>() : skipPhases); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(snap)); } @Override public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { - send(ProtocolMethod.setUiShouldSkipPhase, turnPlayer, phase, shouldSkip); + yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetSkipPhase(turnPlayer, phase, shouldSkip)); + } + + @Override + public void applyYieldUpdate(final YieldUpdate update) { + if (update instanceof YieldUpdate.SetMarker u) { + yieldController.setMarker(u.phaseOwner(), u.phase()); + } else if (update instanceof YieldUpdate.ClearMarker) { + yieldController.clearMarker(); + } else if (update instanceof YieldUpdate.SetStackYield u) { + yieldController.setStackYield(u.active()); + } else if (update instanceof YieldUpdate.SetTriggerDecision u) { + yieldController.setTriggerDecision(u.trigId(), u.decision()); + } else if (update instanceof YieldUpdate.SetCardAutoYield u) { + yieldController.setCardAutoYield(u.cardKey(), u.active(), u.abilityScope()); + } else if (update instanceof YieldUpdate.SetSkipPhase u) { + yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); + } + // SeedFromClient: no-op on client side; client does not apply its own seed. + } + + /** + * User-initiated yield update from local UI: apply to local cache for + * immediate UI response AND ship to host so the authoritative YieldController + * stays in sync. The default IGameController.sendYieldUpdate just calls + * applyYieldUpdate (host-only), which would silently drop the wire send + * for remote clients. + */ + @Override + public void sendYieldUpdate(final YieldUpdate update) { + applyYieldUpdate(update); + send(ProtocolMethod.sendYieldUpdate, update); } private IMacroSystem macros; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index f9193b422e2..f4c62a1a45c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -13,6 +13,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.net.DeltaPacket; import forge.gamemodes.net.GameProtocolSender; @@ -119,6 +120,11 @@ private void send(final ProtocolMethod method, final Object... args) { sender.send(method, args); } + @Override + public void applyYieldUpdate(final YieldUpdate update) { + send(ProtocolMethod.applyYieldUpdate, update); + } + private T sendAndWait(final ProtocolMethod method, final Object... args) { if (paused) { return null; } flushPendingEvents(); diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 24a1f89f3e1..2c71ad178cd 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -15,6 +15,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.match.input.InputConfirm; import forge.gamemodes.net.DeltaPacket; import forge.gui.control.PlaybackSpeed; @@ -261,9 +262,6 @@ default List many(final String title, final String topCaption, final int boolean isUiSetToSkipPhase(PlayerView playerTurn, PhaseType phase); - void autoPassUntilEndOfTurn(PlayerView player); - boolean mayAutoPass(PlayerView player); - void autoPassCancel(PlayerView player); void updateAutoPassPrompt(); void setCurrentPlayer(PlayerView player); @@ -274,6 +272,12 @@ default List many(final String title, final String topCaption, final int */ void applyDelta(DeltaPacket packet); + /** Apply a yield update envelope (server->client direction). */ + void applyYieldUpdate(YieldUpdate update); + + /** Repaint marker chevron / stack-yield UI for the given player. Default no-op. */ + default void refreshYieldUi(PlayerView player) { /* impl per platform */ } + /** Returns true if this game instance is a network game. */ boolean isNetGame(); void setNetGame(); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index b08517d37d9..e52405b1e34 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -7,6 +7,8 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.util.ITriggerEvent; public interface IGameController { @@ -74,4 +76,18 @@ public interface IGameController { void setShouldAlwaysAskTrigger(int trigger); void setUiShouldSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean shouldSkip); + + /** Apply a unified yield update envelope to this controller's YieldController. */ + void applyYieldUpdate(YieldUpdate update); + + /** + * Wire entry for client->host yield update; receivers route to applyYieldUpdate. + * Named to match the {@code sendYieldUpdate} ProtocolMethod (lookup is by name). + */ + default void sendYieldUpdate(YieldUpdate update) { + applyYieldUpdate(update); + } + + /** Access this controller's YieldController. */ + YieldController getYieldController(); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 3d4a9186cc9..40409a75436 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -48,6 +48,8 @@ import forge.game.zone.Zone; import forge.game.zone.ZoneType; import forge.gamemodes.match.NextGameDecision; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.match.input.*; import forge.util.IHasForgeLog; import forge.gui.FThreads; @@ -100,16 +102,7 @@ public class PlayerControllerHuman extends PlayerController implements IGameCont private IGuiGame gui; - // Inlined server-side mirror used only when this controller serves a remote network - // player (gui instanceof RemoteClientGuiGame). Two scope buckets so the host doesn't - // need to know the client's UI_AUTO_YIELD_MODE: the client tells us which bucket via - // the isAbilityScope flag on setShouldAutoYield. - private final Set remoteCardYields = Sets.newHashSet(); - private final Set remoteAbilityYields = Sets.newHashSet(); - private final Map remoteTriggerDecisions = Maps.newTreeMap(); - private boolean remoteAutoYieldsDisabled; - // Empty/missing entry returns false (don't skip), matching a fresh UI's conservative default - private final Map> remoteSkipPhases = Maps.newHashMap(); + private final YieldController yieldController = new YieldController(this); protected final InputQueue inputQueue; protected final InputProxy inputProxy; @@ -139,6 +132,10 @@ public final void setGui(final IGuiGame gui) { this.gui = gui; } + public YieldController getYieldController() { + return yieldController; + } + public final InputQueue getInputQueue() { return inputQueue; } @@ -3346,20 +3343,28 @@ public void concede() { } public boolean mayAutoPass() { - return getGui().mayAutoPass(getLocalPlayerView()); + return yieldController.shouldAutoYield(); } public void autoPassUntilEndOfTurn() { - getGui().autoPassUntilEndOfTurn(getLocalPlayerView()); + yieldController.setAutoPassUntilEndOfTurn(true); + if (getGui() != null) { + getGui().updateAutoPassPrompt(); + } } @Override public void autoPassCancel() { - if (getGui() == null) { + if (!yieldController.shouldAutoYield()) { return; } - - getGui().autoPassCancel(getLocalPlayerView()); + yieldController.cancelYield(); + if (getGui() != null) { + PlayerView playerView = getLocalPlayerView(); + getGui().showPromptMessage(playerView, ""); + getGui().updateButtons(playerView, false, false, false); + getGui().awaitNextInput(); + } } @Override @@ -3501,9 +3506,9 @@ private AutoYieldStore.Tier activeTier() { @Override public boolean shouldAutoYield(final String key) { if (isRemoteClient()) { - if (remoteAutoYieldsDisabled) return false; - if (remoteCardYields.contains(key)) return true; - return remoteAbilityYields.contains(AutoYieldStore.abilitySuffix(key)); + if (yieldController.isAutoYieldsDisabled()) return false; + if (yieldController.shouldAutoYieldKey(key, false)) return true; + return yieldController.shouldAutoYieldKey(AutoYieldStore.abilitySuffix(key), true); } if (localStore().isDisabled()) return false; if (activeModeIsInstall()) { @@ -3517,9 +3522,7 @@ public boolean shouldAutoYield(final String key) { @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { if (isRemoteClient()) { - Set bucket = isAbilityScope ? remoteAbilityYields : remoteCardYields; - if (autoYield) bucket.add(key); - else bucket.remove(key); + yieldController.setCardAutoYield(key, autoYield, isAbilityScope); return; } String storageKey = isAbilityScope ? AutoYieldStore.abilitySuffix(key) : key; @@ -3533,7 +3536,7 @@ public void setShouldAutoYield(final String key, final boolean autoYield, final @Override public Iterable getAutoYields() { if (isRemoteClient()) { - return Iterables.concat(remoteCardYields, remoteAbilityYields); + return Iterables.concat(yieldController.getCardYields(), yieldController.getAbilityYields()); } if (activeModeIsInstall()) return PersistentYieldStore.get().getYields(); return localStore().getYields(activeTier()); @@ -3542,9 +3545,7 @@ public Iterable getAutoYields() { @Override public void clearAutoYields() { if (isRemoteClient()) { - remoteCardYields.clear(); - remoteAbilityYields.clear(); - remoteTriggerDecisions.clear(); + yieldController.clearAutoYields(); return; } localStore().onGameEnd(getGame() == null || getGame().getView().isMatchOver()); @@ -3552,37 +3553,37 @@ public void clearAutoYields() { @Override public boolean getDisableAutoYields() { - return isRemoteClient() ? remoteAutoYieldsDisabled : localStore().isDisabled(); + return isRemoteClient() ? yieldController.isAutoYieldsDisabled() : localStore().isDisabled(); } @Override public void setDisableAutoYields(final boolean disable) { - if (isRemoteClient()) remoteAutoYieldsDisabled = disable; + if (isRemoteClient()) yieldController.setAutoYieldsDisabled(disable); else localStore().setDisabled(disable); } @Override public boolean shouldAlwaysAcceptTrigger(final int trigger) { - if (isRemoteClient()) return Boolean.TRUE.equals(remoteTriggerDecisions.get(trigger)); + if (isRemoteClient()) return yieldController.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; return localStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; } @Override public boolean shouldAlwaysDeclineTrigger(final int trigger) { - if (isRemoteClient()) return Boolean.FALSE.equals(remoteTriggerDecisions.get(trigger)); + if (isRemoteClient()) return yieldController.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; return localStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; } @Override public void setShouldAlwaysAcceptTrigger(final int trigger) { - if (isRemoteClient()) remoteTriggerDecisions.put(trigger, Boolean.TRUE); + if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); if (isPromptingForTrigger(trigger)) selectButtonOk(); } @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { - if (isRemoteClient()) remoteTriggerDecisions.put(trigger, Boolean.FALSE); + if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); if (isPromptingForTrigger(trigger)) selectButtonCancel(); } @@ -3595,14 +3596,13 @@ private boolean isPromptingForTrigger(final int trigger) { @Override public void setShouldAlwaysAskTrigger(final int trigger) { - if (isRemoteClient()) remoteTriggerDecisions.remove(trigger); + if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); } public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType phase) { if (isRemoteClient()) { - EnumSet set = remoteSkipPhases.get(turnPlayer); - return set != null && set.contains(phase); + return yieldController.isSkippingPhase(turnPlayer, phase); } return getGui().isUiSetToSkipPhase(turnPlayer, phase); } @@ -3610,9 +3610,25 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p @Override public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { if (!isRemoteClient()) return; - EnumSet set = remoteSkipPhases.computeIfAbsent(turnPlayer, - k -> EnumSet.noneOf(PhaseType.class)); - if (shouldSkip) set.add(phase); - else set.remove(phase); + yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); + } + + @Override + public void applyYieldUpdate(final YieldUpdate update) { + if (update instanceof YieldUpdate.SetMarker u) { + yieldController.setMarker(u.phaseOwner(), u.phase()); + } else if (update instanceof YieldUpdate.ClearMarker) { + yieldController.clearMarker(); + } else if (update instanceof YieldUpdate.SetStackYield u) { + yieldController.setStackYield(u.active()); + } else if (update instanceof YieldUpdate.SetTriggerDecision u) { + yieldController.setTriggerDecision(u.trigId(), u.decision()); + } else if (update instanceof YieldUpdate.SetCardAutoYield u) { + yieldController.setCardAutoYield(u.cardKey(), u.active(), u.abilityScope()); + } else if (update instanceof YieldUpdate.SetSkipPhase u) { + yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); + } else if (update instanceof YieldUpdate.SeedFromClient u) { + yieldController.applyClientSeed(u.snapshot()); + } } } From e2e55ef5739b2b802a3205fdc606fde8e37ae0e7 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:24:51 +0930 Subject: [PATCH 02/21] Remove dead setUiShouldSkipPhase from IGameController/PCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire path for skip-phase updates now goes SetSkipPhase(turnPlayer, phase, skip) -> PCH.applyYieldUpdate switch -> yieldController.setSkipPhase(...), bypassing the old per-method IGameController.setUiShouldSkipPhase entry. PCH's override of that method was no longer reachable. NetGameController.setUiShouldSkipPhase stays — still called via instanceof cast in NetworkGuiGame.pushSkipPhaseToControllers when the GUI's PhaseLabel toggles fire — but no longer overrides an interface method. Also drops two YieldController accessors that had no callers: getTriggerDecisions and getSkipPhases. They were defensively added during the Phase 3 field migration as enumeration accessors, but the only call sites that would have needed them (snapshot building) read from GUI state and the AutoYieldStore directly. Single-key getters (getTriggerDecision, isSkippingPhase) cover every actual use. The PhaseType import in IGameController was load-bearing only for the deleted setUiShouldSkipPhase signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/forge/gamemodes/match/YieldController.java | 8 -------- .../forge/gamemodes/net/client/NetGameController.java | 1 - .../src/main/java/forge/interfaces/IGameController.java | 3 --- .../src/main/java/forge/player/PlayerControllerHuman.java | 6 ------ 4 files changed, 18 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 332d6f2be79..6b4f7a01d7e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -157,10 +157,6 @@ public AutoYieldStore.TriggerDecision getTriggerDecision(int trigId) { return triggerDecisions.getOrDefault(trigId, AutoYieldStore.TriggerDecision.ASK); } - public Map getTriggerDecisions() { - return Collections.unmodifiableMap(triggerDecisions); - } - public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { if (turnPlayer == null || phase == null) return; EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); @@ -173,10 +169,6 @@ public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { return set != null && set.contains(phase); } - public Map> getSkipPhases() { - return Collections.unmodifiableMap(skipPhases); - } - public void setMarker(PlayerView phaseOwner, PhaseType phase) { autoPassUntilEndOfTurn = false; if (phaseOwner == null || phase == null) { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 0c4e0b46e12..c85275d214b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -263,7 +263,6 @@ public void seedYieldStateOnHost(Map> skipPhases) send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(snap)); } - @Override public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetSkipPhase(turnPlayer, phase, shouldSkip)); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index e52405b1e34..811fcdb4ae8 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -3,7 +3,6 @@ import java.util.List; import forge.game.card.CardView; -import forge.game.phase.PhaseType; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; @@ -75,8 +74,6 @@ public interface IGameController { void setShouldAlwaysDeclineTrigger(int trigger); void setShouldAlwaysAskTrigger(int trigger); - void setUiShouldSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean shouldSkip); - /** Apply a unified yield update envelope to this controller's YieldController. */ void applyYieldUpdate(YieldUpdate update); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 40409a75436..f50fd9fbeca 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3607,12 +3607,6 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p return getGui().isUiSetToSkipPhase(turnPlayer, phase); } - @Override - public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { - if (!isRemoteClient()) return; - yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); - } - @Override public void applyYieldUpdate(final YieldUpdate update) { if (update instanceof YieldUpdate.SetMarker u) { From 5f0e49d7157cffadb52429832a6f8724d07b2faf Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:01:12 +0930 Subject: [PATCH 03/21] Lift PhaseLabel right-click yield-marker handling into match controller Both PhaseLabel (desktop) and VPhaseIndicator.PhaseLabel (mobile) had two click paths: a Runnable-callback "onToggled" for left-click skip- phase wiring, and an inline right-click / long-press handler that reached statically into CMatchUI / MatchController to dispatch yield- marker updates. Unify on the callback pattern. The view widgets now only expose setOnRightClick / setOnLongPress hooks; CMatchUI.actuateMatchPreferences and MatchController.actuateMatchPreferences register the marker logic next to the existing setOnToggled wiring. PhaseLabel drops 5 imports (PlayerView, YieldMarker, YieldUpdate, IGameController, CMatchUI) plus its phaseOwner field; the now-redundant PhaseIndicator.setOwner push and VField/VPlayerPanel call sites also go away. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 29 ++++++++++ .../forge/screens/match/views/VField.java | 1 - .../forge/toolbox/special/PhaseIndicator.java | 8 --- .../forge/toolbox/special/PhaseLabel.java | 58 +++---------------- .../forge/screens/match/MatchController.java | 27 +++++++++ .../screens/match/views/VPhaseIndicator.java | 42 +++----------- .../screens/match/views/VPlayerPanel.java | 1 - 7 files changed, 73 insertions(+), 93 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 69c46dceeaa..2ab0d1163e7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -68,6 +68,7 @@ import forge.game.zone.ZoneType; import forge.util.IHasForgeLog; import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; import forge.interfaces.IGameController; import forge.gui.FNetOverlay; @@ -1355,12 +1356,40 @@ private void actuateMatchPreferences() { final PhaseLabel label = pi.getLabelFor(phase); label.setEnabled(prefs.getPrefBoolean(keys[p - 1])); label.setOnToggled(() -> pushSkipPhaseToControllers(player, phase)); + label.setOnRightClick(() -> handleYieldMarkerToggle(label, player, phase)); } } seedYieldStateOnHost(); } + private void handleYieldMarkerToggle(final PhaseLabel label, final PlayerView phaseOwner, final PhaseType phase) { + PlayerView local = getCurrentPlayer(); + if (local == null) { + return; + } + IGameController controller = getGameController(local); + if (controller == null) { + return; + } + YieldMarker existing = controller.getYieldController().getMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phase == existing.getPhase(); + if (clickedSameLabel) { + controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } else { + // Setting a marker implies we want to stop here — un-skip the cell + // so the marker can fire (skip-phase pref + marker would skip past). + label.setEnabled(true); + label.repaintOnlyThisLabel(); + controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase)); + // Pass current priority so the marker takes effect immediately. + controller.selectButtonOk(); + } + refreshYieldUi(local); + } + @Override public void message(final String message, final String title) { SOptionPane.showMessageDialog(message, title); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java index 7fbc4c98d22..b463b915aed 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VField.java @@ -99,7 +99,6 @@ public VField(final CMatchUI matchUI, final EDocID id0, final PlayerView p, fina this.docID = id0; this.player = p; - phaseIndicator.setOwner(p); if (p != null) { tab.setText(Localizer.getInstance().getMessage("lblPlayField", p.getName())); } else { tab.setText(Localizer.getInstance().getMessage("lblNoPlayerForEDocID", docID.toString())); } diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java index bf74b666769..e04a876053c 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java @@ -6,7 +6,6 @@ import javax.swing.JPanel; import forge.game.phase.PhaseType; -import forge.game.player.PlayerView; import forge.util.Localizer; import net.miginfocom.swing.MigLayout; @@ -114,13 +113,6 @@ public PhaseLabel getLabelFor(final PhaseType s) { } } - /** Push the per-VField player binding to every label so right-click can route to it. */ - public void setOwner(final PlayerView player) { - for (PhaseLabel l : allLabels()) { - l.setPhaseOwner(player); - } - } - public List allLabels() { return Arrays.asList( lblUpkeep, lblDraw, lblMain1, lblBeginCombat, lblDeclareAttackers, diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java index 79d908201e7..4c6fe7af041 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseLabel.java @@ -13,11 +13,6 @@ import javax.swing.SwingUtilities; import forge.game.phase.PhaseType; -import forge.game.player.PlayerView; -import forge.gamemodes.match.YieldMarker; -import forge.gamemodes.match.YieldUpdate; -import forge.interfaces.IGameController; -import forge.screens.match.CMatchUI; import forge.toolbox.FSkin; /** @@ -31,12 +26,12 @@ public class PhaseLabel extends JLabel { private static final Color YIELD_MARKER_COLOR = new Color(0xFFA528); private final PhaseType phaseType; - private PlayerView phaseOwner; private boolean enabled = true; private boolean active = false; private boolean hover = false; private boolean yieldMarked = false; private Runnable onToggled; + private Runnable onRightClick; /** @@ -57,7 +52,9 @@ public PhaseLabel(final String txt, final PhaseType phaseType0) { @Override public void mousePressed(final MouseEvent e) { if (SwingUtilities.isRightMouseButton(e)) { - handleRightClick(); + if (PhaseLabel.this.onRightClick != null) { + PhaseLabel.this.onRightClick.run(); + } return; } PhaseLabel.this.enabled = !PhaseLabel.this.enabled; @@ -84,14 +81,6 @@ public PhaseType getPhaseType() { return phaseType; } - public PlayerView getPhaseOwner() { - return phaseOwner; - } - - public void setPhaseOwner(final PlayerView v) { - this.phaseOwner = v; - } - public boolean isYieldMarked() { return yieldMarked; } @@ -103,40 +92,6 @@ public void setYieldMarked(final boolean b) { } } - private void handleRightClick() { - if (phaseOwner == null || phaseType == null) { - return; - } - CMatchUI ui = CMatchUI.getActive(); - if (ui == null) { - return; - } - PlayerView local = ui.getCurrentPlayer(); - if (local == null) { - return; - } - IGameController controller = ui.getGameController(local); - if (controller == null) { - return; - } - YieldMarker existing = controller.getYieldController().getMarker(); - boolean clickedSameLabel = existing != null - && phaseOwner.equals(existing.getPhaseOwner()) - && phaseType == existing.getPhase(); - if (clickedSameLabel) { - controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - } else { - // Setting a marker implies we want to stop here — un-skip the cell - // so the marker can fire (skip-phase pref + marker would skip past). - this.enabled = true; - repaintOnlyThisLabel(); - controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phaseType)); - // Pass current priority so the marker takes effect immediately. - controller.selectButtonOk(); - } - ui.refreshYieldUi(local); - } - /** * Determines whether play pauses at this phase or not. * @@ -161,6 +116,11 @@ public void setOnToggled(final Runnable r) { this.onToggled = r; } + /** Fires when the user right-clicks this label. */ + public void setOnRightClick(final Runnable r) { + this.onRightClick = r; + } + /** * Makes this phase the current phase (or not). * diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 2ccecd4fa23..97fc6c49ea3 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -39,6 +39,7 @@ import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; import forge.gamemodes.match.YieldMarker; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.match.HostedMatch; import forge.interfaces.IGameController; @@ -573,12 +574,38 @@ private static void actuateMatchPreferences() { final VPhaseIndicator.PhaseLabel label = pi.getLabel(phase); label.setStopAtPhase(prefs.getPrefBoolean(keys[p - 1])); label.setOnToggled(() -> instance.pushSkipPhaseToControllers(player, phase)); + label.setOnLongPress(() -> instance.handleYieldMarkerToggle(label, player, phase)); } } instance.seedYieldStateOnHost(); } + private void handleYieldMarkerToggle(final VPhaseIndicator.PhaseLabel label, final PlayerView phaseOwner, final PhaseType phase) { + PlayerView local = getCurrentPlayer(); + if (local == null) { + return; + } + IGameController controller = getGameController(local); + if (controller == null) { + return; + } + YieldMarker existing = controller.getYieldController().getMarker(); + boolean clickedSameLabel = existing != null + && phaseOwner.equals(existing.getPhaseOwner()) + && phase == existing.getPhase(); + if (clickedSameLabel) { + controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } else { + // Setting a marker implies we want to stop here — un-skip the cell so the marker can fire. + label.setStopAtPhase(true); + controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase)); + // Pass current priority so the marker takes effect immediately. + controller.selectButtonOk(); + } + refreshYieldUi(local); + } + public static void writeMatchPreferences() { final ForgePreferences prefs = FModel.getPreferences(); final List panels = view.getPlayerPanelsList(); diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java index 80b8032494f..63e5db048e5 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java @@ -12,11 +12,6 @@ import forge.assets.FSkinColor.Colors; import forge.assets.FSkinFont; import forge.game.phase.PhaseType; -import forge.game.player.PlayerView; -import forge.gamemodes.match.YieldMarker; -import forge.gamemodes.match.YieldUpdate; -import forge.interfaces.IGameController; -import forge.screens.match.MatchController; import forge.toolbox.FContainer; import forge.toolbox.FDisplayObject; import forge.util.TextBounds; @@ -31,7 +26,6 @@ public class VPhaseIndicator extends FContainer { private final Map phaseLabels = new HashMap<>(); private FSkinFont font; - private PlayerView owner; public VPhaseIndicator() { addPhaseLabel("UP", PhaseType.UPKEEP); @@ -60,10 +54,6 @@ public Iterable allLabels() { return phaseLabels.values(); } - public void setOwner(PlayerView player) { - this.owner = player; - } - public void resetPhaseButtons() { for (PhaseLabel lbl : phaseLabels.values()) { lbl.setActive(false); @@ -128,6 +118,7 @@ public class PhaseLabel extends FDisplayObject { private boolean active = false; private boolean yieldMarked = false; private Runnable onToggled; + private Runnable onLongPress; public PhaseLabel(String caption0, PhaseType phaseType0) { caption = caption0; @@ -164,6 +155,11 @@ public void setOnToggled(Runnable r) { onToggled = r; } + /** Fires when the user long-presses this label. */ + public void setOnLongPress(Runnable r) { + onLongPress = r; + } + @Override public boolean tap(float x, float y, int count) { stopAtPhase = !stopAtPhase; @@ -173,32 +169,10 @@ public boolean tap(float x, float y, int count) { @Override public boolean longPress(float x, float y) { - PlayerView phaseOwner = VPhaseIndicator.this.owner; - if (phaseOwner == null) { - return false; - } - PlayerView local = MatchController.instance.getCurrentPlayer(); - if (local == null) { - return false; - } - IGameController ctrl = MatchController.instance.getGameController(local); - if (ctrl == null) { + if (onLongPress == null) { return false; } - YieldMarker existing = ctrl.getYieldController().getMarker(); - boolean clickedSameLabel = existing != null - && phaseOwner.equals(existing.getPhaseOwner()) - && phaseType == existing.getPhase(); - if (clickedSameLabel) { - ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - } else { - // Setting a marker implies we want to stop here — un-skip the cell so the marker can fire. - stopAtPhase = true; - ctrl.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phaseType)); - // Pass current priority so the marker takes effect immediately. - ctrl.selectButtonOk(); - } - MatchController.instance.refreshYieldUi(local); + onLongPress.run(); return true; } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java index 5bf0a89626f..25735826693 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPlayerPanel.java @@ -94,7 +94,6 @@ private static FSkinColor getDeliriumHighlight() { public VPlayerPanel(PlayerView player0, boolean showHand, int playerCount) { player = player0; phaseIndicator = add(new VPhaseIndicator()); - phaseIndicator.setOwner(player); if (playerCount > 2) { forMultiPlayer = true; From 64ead6f3948e3ded7ec66a15a08e4ae39dae3c66 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Wed, 29 Apr 2026 15:39:02 +0200 Subject: [PATCH 04/21] Clean up --- .../forge/screens/match/views/VPrompt.java | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index af4686b5ea2..804309a94db 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -71,10 +71,10 @@ public class VPrompt implements IVDoc { private final FScrollPane messageScroller = new FScrollPane(tarMessage, false, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); private final JLabel lblGames; - private CardView card = null ; + private CardView card = null; public void setCardView(final CardView card) { - this.card = card ; + this.card = card; } private KeyAdapter buttonKeyAdapter = new KeyAdapter() { @@ -105,10 +105,9 @@ public void keyPressed(final KeyEvent e) { } } } - if (btnCancel.isEnabled()) { - if (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals("End Turn")) { - btnCancel.doClick(); - } + if (btnCancel.isEnabled() && + (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { + btnCancel.doClick(); } } } @@ -116,16 +115,15 @@ public void keyPressed(final KeyEvent e) { private final CPrompt controller; - //========= Constructor public VPrompt(final CPrompt controller) { this.controller = controller; lblGames = new FLabel.Builder() - .fontSize(12) - .fontStyle(Font.PLAIN) - .fontAlign(SwingConstants.CENTER) - .opaque() - .build(); + .fontSize(12) + .fontStyle(Font.PLAIN) + .fontAlign(SwingConstants.CENTER) + .opaque() + .build(); btnOK.addKeyListener(buttonKeyAdapter); btnCancel.addKeyListener(buttonKeyAdapter); @@ -137,15 +135,13 @@ public VPrompt(final CPrompt controller) { messageScroller.getViewport().getView().addMouseListener(new MouseAdapter() { @Override public void mouseEntered(final MouseEvent e) { - if ( card != null ) { - controller.getMatchUI().setCard(card); + if (card != null) { + controller.getMatchUI().setCard(card); } } }); } - //========== Overridden methods - /* (non-Javadoc) * @see forge.gui.framework.IVDoc#populate() */ From d4f7a482f02a0617ffa8e8500281e590ac618d52 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:45:07 +0930 Subject: [PATCH 05/21] Move auto-yield/trigger query+mutation into YieldController PCH and NetGameController had a parallel local-vs-remote-or-cache fork in every yield method (shouldAutoYield, setShouldAutoYield, getAutoYields, clearAutoYields, get/setDisableAutoYields, shouldAlways*Trigger, setShouldAlways*Trigger). Each side decided where to read or write based on isRemoteClient() and routed to either the LobbyPlayer's persistent AutoYieldStore or YieldController's per-controller cache fields. YieldController now owns one AutoYieldStore resolved by activeStore(): LobbyPlayer's persistent store for host PCH controlling a local player, or its own store as a per-game cache for host PCH controlling a remote player and as a session-lifetime store for NetGameController. Tier and install-mode logic that consulted FPref also moves into YieldController. PCH's eleven yield methods become one-line delegators; NetGameController loses its parallel yieldStore field and the duplicate FPref helpers. The SetCardAutoYield and SetTriggerDecision arms in NetGameController.applyYieldUpdate are dropped - server never pushes those, and user-initiated setters bypass local-apply. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gamemodes/match/YieldController.java | 242 ++++++++++++------ .../net/client/NetGameController.java | 88 ++----- .../java/forge/player/AutoYieldStore.java | 7 + .../forge/player/PlayerControllerHuman.java | 75 +----- 4 files changed, 203 insertions(+), 209 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 6b4f7a01d7e..126fd983ee4 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -3,10 +3,14 @@ import forge.game.GameView; import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.player.AutoYieldStore; +import forge.player.LobbyPlayerHuman; +import forge.player.PersistentYieldStore; import forge.player.PlayerControllerHuman; -import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -18,9 +22,16 @@ * autopass-until-end-of-turn, per-card/ability auto-yield, trigger * decisions, and skip-phase prefs. * - * Host-side instances are authoritative (full Game access). Client-side - * instances on NetGameController are caches populated by SERVER-mode wire - * messages. + *

Auto-yield state and trigger decisions live in a single {@link AutoYieldStore} + * resolved by {@link #activeStore()}: + *

    + *
  • Host PCH for a local player → the {@link LobbyPlayerHuman}'s persistent store + * (tier-aware via FPref).
  • + *
  • Host PCH for a remote player → a per-game {@link #localStore} cache populated + * by client wire messages.
  • + *
  • {@link forge.gamemodes.net.client.NetGameController}'s controller (owner == null) + * → its session-lifetime {@link #localStore}, tier-aware via the local user's FPref.
  • + *
*/ public class YieldController { @@ -37,11 +48,12 @@ public class YieldController { /** Survives opponent spells; auto-clears only when stack empties, NOT on cancelYield. */ private boolean stackYield; - private final Set cardYields = new HashSet<>(); - private final Set abilityYields = new HashSet<>(); - private boolean autoYieldsDisabled; - - private final Map triggerDecisions = new HashMap<>(); + /** + * Backing store used in cache mode (host PCH for remote, NetGameController on + * client). For host PCH for a local player, {@link #activeStore()} returns the + * LobbyPlayer's persistent store and this field is unused. + */ + private final AutoYieldStore localStore = new AutoYieldStore(); private final Map> skipPhases = new HashMap<>(); @@ -111,64 +123,6 @@ public void cancelYield() { autoPassUntilEndOfTurn = false; } - public void setCardAutoYield(String key, boolean active, boolean abilityScope) { - Set bucket = abilityScope ? abilityYields : cardYields; - if (active) bucket.add(key); - else bucket.remove(key); - } - - public boolean shouldAutoYieldKey(String key, boolean abilityScope) { - if (autoYieldsDisabled) return false; - Set bucket = abilityScope ? abilityYields : cardYields; - return bucket.contains(key); - } - - public Set getCardYields() { - return Collections.unmodifiableSet(cardYields); - } - - public Set getAbilityYields() { - return Collections.unmodifiableSet(abilityYields); - } - - public void clearAutoYields() { - cardYields.clear(); - abilityYields.clear(); - triggerDecisions.clear(); - } - - public boolean isAutoYieldsDisabled() { - return autoYieldsDisabled; - } - - public void setAutoYieldsDisabled(boolean disabled) { - this.autoYieldsDisabled = disabled; - } - - public void setTriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) { - if (decision == null || decision == AutoYieldStore.TriggerDecision.ASK) { - triggerDecisions.remove(trigId); - } else { - triggerDecisions.put(trigId, decision); - } - } - - public AutoYieldStore.TriggerDecision getTriggerDecision(int trigId) { - return triggerDecisions.getOrDefault(trigId, AutoYieldStore.TriggerDecision.ASK); - } - - public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { - if (turnPlayer == null || phase == null) return; - EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); - if (skip) set.add(phase); - else set.remove(phase); - } - - public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { - EnumSet set = skipPhases.get(turnPlayer); - return set != null && set.contains(phase); - } - public void setMarker(PlayerView phaseOwner, PhaseType phase) { autoPassUntilEndOfTurn = false; if (phaseOwner == null || phase == null) { @@ -207,15 +161,153 @@ private boolean isPriorityAt(YieldMarker m) { && phase == m.getPhase(); } - /** Atomic seed of client-persistent state at game start or reconnection. */ + public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { + if (turnPlayer == null || phase == null) return; + EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); + if (skip) set.add(phase); + else set.remove(phase); + } + + public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { + EnumSet set = skipPhases.get(turnPlayer); + return set != null && set.contains(phase); + } + + // ---- Auto-yield (per-card/ability) and trigger decisions ---- + + /** + * Cache mode (host PCH for remote, or NetGameController) → {@link #localStore}. + * Tier-aware mode (host PCH for local player) → LobbyPlayer's persistent store. + */ + private AutoYieldStore activeStore() { + if (owner != null && !owner.isRemoteClient()) { + return ((LobbyPlayerHuman) owner.getLobbyPlayer()).getYieldStore(); + } + return localStore; + } + + /** True if FPref tier/install logic applies (local user context). False for the host's remote-cache mode. */ + private boolean tierAware() { + return owner == null || !owner.isRemoteClient(); + } + + private static boolean activeModeIsInstall() { + return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals( + FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); + } + + private static AutoYieldStore.Tier activeTier() { + String mode = FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE); + if (ForgeConstants.AUTO_YIELD_PER_CARD.equals(mode)) return AutoYieldStore.Tier.GAME; + if (ForgeConstants.AUTO_YIELD_PER_ABILITY_SESSION.equals(mode)) return AutoYieldStore.Tier.SESSION; + return AutoYieldStore.Tier.MATCH; + } + + public boolean shouldAutoYield(String key) { + AutoYieldStore store = activeStore(); + if (store.isDisabled()) return false; + if (!tierAware()) { + // Cache: keys stored at storageKey shape (full or stripped). Check both. + return store.shouldYield(AutoYieldStore.Tier.GAME, key) + || store.shouldYield(AutoYieldStore.Tier.GAME, AutoYieldStore.abilitySuffix(key)); + } + if (activeModeIsInstall()) { + return PersistentYieldStore.get().contains(AutoYieldStore.abilitySuffix(key)); + } + AutoYieldStore.Tier tier = activeTier(); + boolean abilityScope = tier != AutoYieldStore.Tier.GAME; + String storageKey = abilityScope ? AutoYieldStore.abilitySuffix(key) : key; + return store.shouldYield(tier, storageKey); + } + + /** Tier-aware user-initiated set. Returns the storage key (stripped if ability-scope) for wire propagation. */ + public String setShouldAutoYield(String key, boolean autoYield, boolean abilityScope) { + String storageKey = abilityScope ? AutoYieldStore.abilitySuffix(key) : key; + if (activeModeIsInstall()) { + PersistentYieldStore.get().setYield(storageKey, autoYield); + } else { + activeStore().setYield(activeTier(), storageKey, autoYield); + } + return storageKey; + } + + /** Cache-mode write of a wire-received update. Storage key is already at the right shape. */ + public void applyAutoYieldFromWire(String storageKey, boolean active) { + activeStore().setYield(AutoYieldStore.Tier.GAME, storageKey, active); + } + + public Iterable getAutoYields() { + AutoYieldStore store = activeStore(); + if (!tierAware()) return store.getYields(AutoYieldStore.Tier.GAME); + if (activeModeIsInstall()) return PersistentYieldStore.get().getYields(); + return store.getYields(activeTier()); + } + + public void clearAutoYields() { + if (!tierAware()) { + localStore.clear(); + return; + } + boolean matchOver = owner.getGame() == null || owner.getGame().getView().isMatchOver(); + activeStore().onGameEnd(matchOver); + } + + public boolean getDisableAutoYields() { + return activeStore().isDisabled(); + } + + public void setDisableAutoYields(boolean disable) { + activeStore().setDisabled(disable); + } + + public boolean shouldAlwaysAcceptTrigger(int trigger) { + return activeStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; + } + + public boolean shouldAlwaysDeclineTrigger(int trigger) { + return activeStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; + } + + public void setAlwaysAcceptTrigger(int trigger) { + activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); + } + + public void setAlwaysDeclineTrigger(int trigger) { + activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); + } + + public void setAlwaysAskTrigger(int trigger) { + activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); + } + + public void setTriggerDecision(int trigger, AutoYieldStore.TriggerDecision decision) { + activeStore().setTriggerDecision(trigger, decision); + } + + /** Build the seed payload from this controller's authoritative store. */ + public YieldStateSnapshot buildClientSnapshot(Map> skipPhases) { + Set cardYields = new HashSet<>(); + Set abilityYields = new HashSet<>(); + boolean abilityScope = activeModeIsInstall() || activeTier() != AutoYieldStore.Tier.GAME; + for (String key : getAutoYields()) { + if (abilityScope) abilityYields.add(key); + else cardYields.add(key); + } + // Trigger decisions are per-game; deltas flow during play. + Map triggers = new HashMap<>(); + return new YieldStateSnapshot(cardYields, abilityYields, triggers, getDisableAutoYields(), + skipPhases == null ? new HashMap<>() : skipPhases); + } + + /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ public void applyClientSeed(YieldStateSnapshot snap) { - cardYields.clear(); - cardYields.addAll(snap.cardYields()); - abilityYields.clear(); - abilityYields.addAll(snap.abilityYields()); - triggerDecisions.clear(); - triggerDecisions.putAll(snap.triggerDecisions()); - autoYieldsDisabled = snap.autoYieldsDisabled(); + localStore.clear(); + for (String k : snap.cardYields()) localStore.setYield(AutoYieldStore.Tier.GAME, k, true); + for (String k : snap.abilityYields()) localStore.setYield(AutoYieldStore.Tier.GAME, k, true); + for (Map.Entry e : snap.triggerDecisions().entrySet()) { + localStore.setTriggerDecision(e.getKey(), e.getValue()); + } + localStore.setDisabled(snap.autoYieldsDisabled()); skipPhases.clear(); for (Map.Entry> e : snap.skipPhases().entrySet()) { skipPhases.put(e.getKey(), EnumSet.copyOf(e.getValue())); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index c85275d214b..4e246df1c00 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -7,34 +7,24 @@ import forge.game.spellability.SpellAbilityView; import forge.gamemodes.match.NextGameDecision; import forge.gamemodes.match.YieldController; -import forge.gamemodes.match.YieldStateSnapshot; import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.GameProtocolSender; import forge.gamemodes.net.ProtocolMethod; import forge.interfaces.IDevModeCheats; import forge.interfaces.IGameController; import forge.interfaces.IMacroSystem; -import forge.localinstance.properties.ForgeConstants; -import forge.localinstance.properties.ForgePreferences; -import forge.model.FModel; import forge.player.AutoYieldStore; -import forge.player.PersistentYieldStore; import forge.util.ITriggerEvent; import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; public class NetGameController implements IGameController { private final GameProtocolSender sender; - private final AutoYieldStore yieldStore = new AutoYieldStore(); - - /** Local cache mirroring host-side state for client-side UI rendering. */ + /** Source of truth for this client's yield state (auto-yields, trigger decisions, markers, skip-phase, etc.). */ private final YieldController yieldController = new YieldController(null); public NetGameController(final IToServer server) { @@ -151,51 +141,21 @@ public void requestResync() { send(ProtocolMethod.requestResync); } - private boolean activeModeIsInstall() { - return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals( - FModel.getPreferences().getPref(ForgePreferences.FPref.UI_AUTO_YIELD_MODE)); - } - - private boolean activeModeIsAbilityScope() { - return !ForgeConstants.AUTO_YIELD_PER_CARD.equals( - FModel.getPreferences().getPref(ForgePreferences.FPref.UI_AUTO_YIELD_MODE)); - } - - private AutoYieldStore.Tier activeTier() { - String mode = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_AUTO_YIELD_MODE); - if (ForgeConstants.AUTO_YIELD_PER_CARD.equals(mode)) return AutoYieldStore.Tier.GAME; - if (ForgeConstants.AUTO_YIELD_PER_ABILITY_SESSION.equals(mode)) return AutoYieldStore.Tier.SESSION; - return AutoYieldStore.Tier.MATCH; - } - @Override public boolean shouldAutoYield(final String key) { - if (yieldStore.isDisabled()) return false; - if (activeModeIsInstall()) { - return PersistentYieldStore.get().contains(AutoYieldStore.abilitySuffix(key)); - } - String storageKey = activeModeIsAbilityScope() ? AutoYieldStore.abilitySuffix(key) : key; - return yieldStore.shouldYield(activeTier(), storageKey); + return yieldController.shouldAutoYield(key); } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { - String storageKey = isAbilityScope ? AutoYieldStore.abilitySuffix(key) : key; - if (activeModeIsInstall()) { - PersistentYieldStore.get().setYield(storageKey, autoYield); - } else { - yieldStore.setYield(activeTier(), storageKey, autoYield); - } - yieldController.setCardAutoYield(storageKey, autoYield, isAbilityScope); + String storageKey = yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetCardAutoYield(storageKey, autoYield, isAbilityScope)); } @Override public Iterable getAutoYields() { - return activeModeIsInstall() - ? PersistentYieldStore.get().getYields() - : yieldStore.getYields(activeTier()); + return yieldController.getAutoYields(); } @Override @@ -204,41 +164,38 @@ public void clearAutoYields() { } @Override - public boolean getDisableAutoYields() { return yieldStore.isDisabled(); } + public boolean getDisableAutoYields() { return yieldController.getDisableAutoYields(); } @Override - public void setDisableAutoYields(final boolean disable) { yieldStore.setDisabled(disable); } + public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); } @Override public boolean shouldAlwaysAcceptTrigger(final int trigger) { - return yieldStore.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; + return yieldController.shouldAlwaysAcceptTrigger(trigger); } @Override public boolean shouldAlwaysDeclineTrigger(final int trigger) { - return yieldStore.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; + return yieldController.shouldAlwaysDeclineTrigger(trigger); } @Override public void setShouldAlwaysAcceptTrigger(final int trigger) { - yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); - yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); + yieldController.setAlwaysAcceptTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); } @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { - yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); - yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); + yieldController.setAlwaysDeclineTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE)); } @Override public void setShouldAlwaysAskTrigger(final int trigger) { - yieldStore.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); - yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); + yieldController.setAlwaysAskTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK)); } @@ -248,19 +205,8 @@ public void setShouldAlwaysAskTrigger(final int trigger) { * GUI-loaded skip-phase prefs and ship it to the host in one wire message. */ public void seedYieldStateOnHost(Map> skipPhases) { - Set cardYields = new HashSet<>(); - Set abilityYields = new HashSet<>(); - boolean abilityScope = activeModeIsAbilityScope(); - for (String key : getAutoYields()) { - if (abilityScope) abilityYields.add(key); - else cardYields.add(key); - } - // Trigger decisions are per-game; deltas flow during play. - Map triggers = new HashMap<>(); - YieldStateSnapshot snap = new YieldStateSnapshot( - cardYields, abilityYields, triggers, yieldStore.isDisabled(), - skipPhases == null ? new HashMap<>() : skipPhases); - send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(snap)); + send(ProtocolMethod.sendYieldUpdate, + new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); } public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { @@ -276,14 +222,12 @@ public void applyYieldUpdate(final YieldUpdate update) { yieldController.clearMarker(); } else if (update instanceof YieldUpdate.SetStackYield u) { yieldController.setStackYield(u.active()); - } else if (update instanceof YieldUpdate.SetTriggerDecision u) { - yieldController.setTriggerDecision(u.trigId(), u.decision()); - } else if (update instanceof YieldUpdate.SetCardAutoYield u) { - yieldController.setCardAutoYield(u.cardKey(), u.active(), u.abilityScope()); } else if (update instanceof YieldUpdate.SetSkipPhase u) { yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } - // SeedFromClient: no-op on client side; client does not apply its own seed. + // SetCardAutoYield/SetTriggerDecision: server never pushes these to the client, and + // user-initiated setters write directly to yieldController + send wire — not via + // sendYieldUpdate's local-apply path. SeedFromClient: no-op on client side. } /** diff --git a/forge-gui/src/main/java/forge/player/AutoYieldStore.java b/forge-gui/src/main/java/forge/player/AutoYieldStore.java index 28ad9f2ff0d..11ead91487f 100644 --- a/forge-gui/src/main/java/forge/player/AutoYieldStore.java +++ b/forge-gui/src/main/java/forge/player/AutoYieldStore.java @@ -50,6 +50,13 @@ public void onGameEnd(boolean matchOver) { } } + /** Wipe all yields, trigger decisions, and the disabled flag — used to reseed the cache from a client snapshot. */ + public void clear() { + for (Set set : yieldsByTier.values()) set.clear(); + triggerDecisions.clear(); + disabled = false; + } + /** Strips the "Card (id=N): " prefix to derive the ability-scope key, or returns the input unchanged. */ public static String abilitySuffix(String rawKey) { return rawKey.contains("): ") ? rawKey.substring(rawKey.indexOf("): ") + 3) : rawKey; diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index f50fd9fbeca..74ef523f01b 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3484,107 +3484,59 @@ public void requestResync() { // No-op for local games - resync is only used for network play } - private boolean isRemoteClient() { + public boolean isRemoteClient() { return gui instanceof forge.gamemodes.net.server.RemoteClientGuiGame; } - private AutoYieldStore localStore() { - return ((LobbyPlayerHuman) getLobbyPlayer()).getYieldStore(); - } - - private boolean activeModeIsInstall() { - return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals(FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); - } - - private AutoYieldStore.Tier activeTier() { - String mode = FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE); - if (ForgeConstants.AUTO_YIELD_PER_CARD.equals(mode)) return AutoYieldStore.Tier.GAME; - if (ForgeConstants.AUTO_YIELD_PER_ABILITY_SESSION.equals(mode)) return AutoYieldStore.Tier.SESSION; - return AutoYieldStore.Tier.MATCH; - } - @Override public boolean shouldAutoYield(final String key) { - if (isRemoteClient()) { - if (yieldController.isAutoYieldsDisabled()) return false; - if (yieldController.shouldAutoYieldKey(key, false)) return true; - return yieldController.shouldAutoYieldKey(AutoYieldStore.abilitySuffix(key), true); - } - if (localStore().isDisabled()) return false; - if (activeModeIsInstall()) { - return PersistentYieldStore.get().contains(AutoYieldStore.abilitySuffix(key)); - } - boolean abilityScope = activeTier() != AutoYieldStore.Tier.GAME; - String storageKey = abilityScope ? AutoYieldStore.abilitySuffix(key) : key; - return localStore().shouldYield(activeTier(), storageKey); + return yieldController.shouldAutoYield(key); } @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { - if (isRemoteClient()) { - yieldController.setCardAutoYield(key, autoYield, isAbilityScope); - return; - } - String storageKey = isAbilityScope ? AutoYieldStore.abilitySuffix(key) : key; - if (activeModeIsInstall()) { - PersistentYieldStore.get().setYield(storageKey, autoYield); - return; - } - localStore().setYield(activeTier(), storageKey, autoYield); + yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); } @Override public Iterable getAutoYields() { - if (isRemoteClient()) { - return Iterables.concat(yieldController.getCardYields(), yieldController.getAbilityYields()); - } - if (activeModeIsInstall()) return PersistentYieldStore.get().getYields(); - return localStore().getYields(activeTier()); + return yieldController.getAutoYields(); } @Override public void clearAutoYields() { - if (isRemoteClient()) { - yieldController.clearAutoYields(); - return; - } - localStore().onGameEnd(getGame() == null || getGame().getView().isMatchOver()); + yieldController.clearAutoYields(); } @Override public boolean getDisableAutoYields() { - return isRemoteClient() ? yieldController.isAutoYieldsDisabled() : localStore().isDisabled(); + return yieldController.getDisableAutoYields(); } @Override public void setDisableAutoYields(final boolean disable) { - if (isRemoteClient()) yieldController.setAutoYieldsDisabled(disable); - else localStore().setDisabled(disable); + yieldController.setDisableAutoYields(disable); } @Override public boolean shouldAlwaysAcceptTrigger(final int trigger) { - if (isRemoteClient()) return yieldController.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; - return localStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; + return yieldController.shouldAlwaysAcceptTrigger(trigger); } @Override public boolean shouldAlwaysDeclineTrigger(final int trigger) { - if (isRemoteClient()) return yieldController.getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; - return localStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; + return yieldController.shouldAlwaysDeclineTrigger(trigger); } @Override public void setShouldAlwaysAcceptTrigger(final int trigger) { - if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); - else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); + yieldController.setAlwaysAcceptTrigger(trigger); if (isPromptingForTrigger(trigger)) selectButtonOk(); } @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { - if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); - else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); + yieldController.setAlwaysDeclineTrigger(trigger); if (isPromptingForTrigger(trigger)) selectButtonCancel(); } @@ -3596,8 +3548,7 @@ private boolean isPromptingForTrigger(final int trigger) { @Override public void setShouldAlwaysAskTrigger(final int trigger) { - if (isRemoteClient()) yieldController.setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); - else localStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); + yieldController.setAlwaysAskTrigger(trigger); } public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType phase) { @@ -3618,7 +3569,7 @@ public void applyYieldUpdate(final YieldUpdate update) { } else if (update instanceof YieldUpdate.SetTriggerDecision u) { yieldController.setTriggerDecision(u.trigId(), u.decision()); } else if (update instanceof YieldUpdate.SetCardAutoYield u) { - yieldController.setCardAutoYield(u.cardKey(), u.active(), u.abilityScope()); + yieldController.applyAutoYieldFromWire(u.cardKey(), u.active()); } else if (update instanceof YieldUpdate.SetSkipPhase u) { yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } else if (update instanceof YieldUpdate.SeedFromClient u) { From 2cbe5b257bf36f5817f66c52093a3913b4b6fe2a Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:47:19 +0930 Subject: [PATCH 06/21] Fix yield marker firing immediately when set on a past phase setMarker only suppressed pastTarget firing when priority was AT the target at activation. Right-clicking a phase already past on the marker owner's current turn left activationOnMarker = false, so the very next shouldAutoYield tick fired and cleared the marker. Visible to clients as the chevron disappearing instead of fast-forwarding to next turn. Treat "priority at-or-past target on owner's turn" as one condition: in both cases the marker must wait for the next turn's occurrence of the target phase. The existing isPriorityAt helper (only used here) becomes isPriorityAtOrPastMarker; setMarker uses it for both hasLeftMarker and activationOnMarker. Other-player-turn activations still take the existing path - pastTarget is gated by inMarkerOwnerTurn in shouldAutoYield, so the misfire is impossible there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/gamemodes/match/YieldController.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 126fd983ee4..fe15604cb1a 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -130,9 +130,11 @@ public void setMarker(PlayerView phaseOwner, PhaseType phase) { return; } marker = new YieldMarker(phaseOwner, phase); - boolean atMarkerNow = isPriorityAt(marker); - hasLeftMarker = !atMarkerNow; - activationOnMarker = atMarkerNow; + // Activating at-or-past target on the owner's current turn must wait for next turn's + // occurrence; otherwise pastTarget would fire and clear the marker on the same turn. + boolean atOrPast = isPriorityAtOrPastMarker(marker); + hasLeftMarker = !atOrPast; + activationOnMarker = atOrPast; } public void clearMarker() { @@ -150,15 +152,15 @@ public void setStackYield(boolean active) { public boolean isStackYieldActive() { return stackYield; } - private boolean isPriorityAt(YieldMarker m) { + private boolean isPriorityAtOrPastMarker(YieldMarker m) { if (m == null || owner == null || owner.getGui() == null) return false; GameView gv = owner.getGui().getGameView(); if (gv == null) return false; PlayerView turnPlayer = gv.getPlayerTurn(); PhaseType phase = gv.getPhase(); - return turnPlayer != null - && turnPlayer.equals(m.getPhaseOwner()) - && phase == m.getPhase(); + if (turnPlayer == null || !turnPlayer.equals(m.getPhaseOwner())) return false; + if (phase == null || m.getPhase() == null) return false; + return phase == m.getPhase() || phase.isAfter(m.getPhase()); } public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { From 94c6a76f78a32c8cb896e8b98c5ce88a4dc04534 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:51:09 +0930 Subject: [PATCH 07/21] Refactor desktop PhaseIndicator to use EnumMap of PhaseType Per Hanmac's review on PR #10555, mirrors mobile's VPhaseIndicator structure: one EnumMap instead of twelve hand-named fields plus their getters and the getLabelFor switch. populatePhase becomes twelve addPhaseLabel calls (caption, phase, tooltip key); resetPhaseButtons becomes a single loop. allLabels returns Iterable to match mobile. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/toolbox/special/PhaseIndicator.java | 213 +++--------------- 1 file changed, 34 insertions(+), 179 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java index e04a876053c..37259b3e5e8 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java @@ -1,7 +1,7 @@ package forge.toolbox.special; -import java.util.Arrays; -import java.util.List; +import java.util.EnumMap; +import java.util.Map; import javax.swing.JPanel; @@ -9,194 +9,49 @@ import forge.util.Localizer; import net.miginfocom.swing.MigLayout; -/** - * TODO: Write javadoc for this type. - * - */ public class PhaseIndicator extends JPanel { private static final long serialVersionUID = -863730022835609252L; - - // Phase labels - private PhaseLabel lblUpkeep = new PhaseLabel("UP", PhaseType.UPKEEP); - private PhaseLabel lblDraw = new PhaseLabel("DR", PhaseType.DRAW); - private PhaseLabel lblMain1 = new PhaseLabel("M1", PhaseType.MAIN1); - private PhaseLabel lblBeginCombat = new PhaseLabel("BC", PhaseType.COMBAT_BEGIN); - private PhaseLabel lblDeclareAttackers = new PhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS); - private PhaseLabel lblDeclareBlockers = new PhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS); - private PhaseLabel lblFirstStrike = new PhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE); - private PhaseLabel lblCombatDamage = new PhaseLabel("CD", PhaseType.COMBAT_DAMAGE); - private PhaseLabel lblEndCombat = new PhaseLabel("EC", PhaseType.COMBAT_END); - private PhaseLabel lblMain2 = new PhaseLabel("M2", PhaseType.MAIN2); - private PhaseLabel lblEndTurn = new PhaseLabel("ET", PhaseType.END_OF_TURN); - private PhaseLabel lblCleanup = new PhaseLabel("CL", PhaseType.CLEANUP); - - - public PhaseIndicator() { - this.setOpaque(false); - this.setLayout(new MigLayout("insets 0 0 1% 0, gap 0, wrap")); - populatePhase(); - } - - /** Adds phase indicator labels to phase area JPanel container. */ - private void populatePhase() { - final Localizer localizer = Localizer.getInstance(); - // Constraints string, set once - final String constraints = "w 94%!, h 7.2%, gaptop 1%, gapleft 3%"; - - lblUpkeep.setToolTipText(localizer.getMessage("htmlPhaseUpkeepTooltip")); - this.add(lblUpkeep, constraints); - - lblDraw.setToolTipText(localizer.getMessage("htmlPhaseDrawTooltip")); - this.add(lblDraw, constraints); - - lblMain1.setToolTipText(localizer.getMessage("htmlPhaseMain1Tooltip")); - this.add(lblMain1, constraints); - - lblBeginCombat.setToolTipText(localizer.getMessage("htmlPhaseBeginCombatTooltip")); - this.add(lblBeginCombat, constraints); - - lblDeclareAttackers.setToolTipText(localizer.getMessage("htmlPhaseDeclareAttackersTooltip")); - this.add(lblDeclareAttackers, constraints); - - lblDeclareBlockers.setToolTipText(localizer.getMessage("htmlPhaseDeclareBlockersTooltip")); - this.add(lblDeclareBlockers, constraints); - - lblFirstStrike.setToolTipText(localizer.getMessage("htmlPhaseFirstStrikeDamageTooltip")); - this.add(lblFirstStrike, constraints); - - lblCombatDamage.setToolTipText(localizer.getMessage("htmlPhaseCombatDamageTooltip")); - this.add(lblCombatDamage, constraints); - - lblEndCombat.setToolTipText(localizer.getMessage("htmlPhaseEndCombatTooltip")); - this.add(lblEndCombat, constraints); - - lblMain2.setToolTipText(localizer.getMessage("htmlPhaseMain2Tooltip")); - this.add(lblMain2, constraints); - - lblEndTurn.setToolTipText(localizer.getMessage("htmlPhaseEndTurnTooltip")); - this.add(lblEndTurn, constraints); - - lblCleanup.setToolTipText(localizer.getMessage("htmlPhaseCleanupTooltip")); - this.add(lblCleanup, constraints); - } - - - //========== Custom class handling - public PhaseLabel getLabelFor(final PhaseType s) { - switch (s) { - case UPKEEP: - return this.getLblUpkeep(); - case DRAW: - return this.getLblDraw(); - case MAIN1: - return this.getLblMain1(); - case COMBAT_BEGIN: - return this.getLblBeginCombat(); - case COMBAT_DECLARE_ATTACKERS: - return this.getLblDeclareAttackers(); - case COMBAT_DECLARE_BLOCKERS: - return this.getLblDeclareBlockers(); - case COMBAT_DAMAGE: - return this.getLblCombatDamage(); - case COMBAT_FIRST_STRIKE_DAMAGE: - return this.getLblFirstStrike(); - case COMBAT_END: - return this.getLblEndCombat(); - case MAIN2: - return this.getLblMain2(); - case END_OF_TURN: - return this.getLblEndTurn(); - case CLEANUP: - return this.getLblCleanup(); - default: - return null; - } - } - - public List allLabels() { - return Arrays.asList( - lblUpkeep, lblDraw, lblMain1, lblBeginCombat, lblDeclareAttackers, - lblDeclareBlockers, lblFirstStrike, lblCombatDamage, lblEndCombat, - lblMain2, lblEndTurn, lblCleanup); - } - - /** - * Resets all phase buttons to "inactive", so highlight won't be drawn on - * them. "Enabled" state remains the same. - */ - public void resetPhaseButtons() { - getLblUpkeep().setActive(false); - getLblDraw().setActive(false); - getLblMain1().setActive(false); - getLblBeginCombat().setActive(false); - getLblDeclareAttackers().setActive(false); - getLblDeclareBlockers().setActive(false); - getLblFirstStrike().setActive(false); - getLblCombatDamage().setActive(false); - getLblEndCombat().setActive(false); - getLblMain2().setActive(false); - getLblEndTurn().setActive(false); - getLblCleanup().setActive(false); - } - - // Phases - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblUpkeep() { - return this.lblUpkeep; - } - - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblDraw() { - return this.lblDraw; - } - - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblMain1() { - return this.lblMain1; - } - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblBeginCombat() { - return this.lblBeginCombat; - } - - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblDeclareAttackers() { - return this.lblDeclareAttackers; - } - - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblDeclareBlockers() { - return this.lblDeclareBlockers; - } + private static final String CONSTRAINTS = "w 94%!, h 7.2%, gaptop 1%, gapleft 3%"; - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblCombatDamage() { - return this.lblCombatDamage; - } + private final Map phaseLabels = new EnumMap<>(PhaseType.class); - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblFirstStrike() { - return this.lblFirstStrike; + public PhaseIndicator() { + this.setOpaque(false); + this.setLayout(new MigLayout("insets 0 0 1% 0, gap 0, wrap")); + addPhaseLabel("UP", PhaseType.UPKEEP, "htmlPhaseUpkeepTooltip"); + addPhaseLabel("DR", PhaseType.DRAW, "htmlPhaseDrawTooltip"); + addPhaseLabel("M1", PhaseType.MAIN1, "htmlPhaseMain1Tooltip"); + addPhaseLabel("BC", PhaseType.COMBAT_BEGIN, "htmlPhaseBeginCombatTooltip"); + addPhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS, "htmlPhaseDeclareAttackersTooltip"); + addPhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS, "htmlPhaseDeclareBlockersTooltip"); + addPhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE, "htmlPhaseFirstStrikeDamageTooltip"); + addPhaseLabel("CD", PhaseType.COMBAT_DAMAGE, "htmlPhaseCombatDamageTooltip"); + addPhaseLabel("EC", PhaseType.COMBAT_END, "htmlPhaseEndCombatTooltip"); + addPhaseLabel("M2", PhaseType.MAIN2, "htmlPhaseMain2Tooltip"); + addPhaseLabel("ET", PhaseType.END_OF_TURN, "htmlPhaseEndTurnTooltip"); + addPhaseLabel("CL", PhaseType.CLEANUP, "htmlPhaseCleanupTooltip"); } - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblEndCombat() { - return this.lblEndCombat; + private void addPhaseLabel(String caption, PhaseType phaseType, String tooltipKey) { + PhaseLabel lbl = new PhaseLabel(caption, phaseType); + lbl.setToolTipText(Localizer.getInstance().getMessage(tooltipKey)); + phaseLabels.put(phaseType, lbl); + add(lbl, CONSTRAINTS); } - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblMain2() { - return this.lblMain2; + public PhaseLabel getLabelFor(final PhaseType phaseType) { + return phaseLabels.get(phaseType); } - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblEndTurn() { - return this.lblEndTurn; + public Iterable allLabels() { + return phaseLabels.values(); } - /** @return {@link javax.swing.JLabel} */ - public PhaseLabel getLblCleanup() { - return this.lblCleanup; + /** Resets all phase buttons to "inactive". "Enabled" state is preserved. */ + public void resetPhaseButtons() { + for (PhaseLabel lbl : phaseLabels.values()) { + lbl.setActive(false); + } } -} \ No newline at end of file +} From fc8dc86abe09f8e12d79715c0c5e0bea9fbe202c Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:58:27 +0930 Subject: [PATCH 08/21] Extract clearActiveYields helper and apply on mobile ESC Per tool4ever's review on PR #10555: the ESC clearing logic duplicated across desktop's VPrompt belongs in YieldController, and mobile MatchScreen.keyDown was missing it entirely. YieldController.clearActiveYields(local, controller) takes a player and an IGameController, clears any active marker / legacy autopass / stack-yield, and returns whether anything was cleared so callers can suppress the ESC fallthrough and refresh the chevron UI. Marker and stack-yield go over the wire via controller.sendYieldUpdate; legacy autopass is local to host PCH (still gated by the existing instanceof PlayerControllerHuman check). Persistent per-card auto-yields are unaffected. Desktop VPrompt's keyPressed handler collapses to a single helper call. Mobile MatchScreen.keyDown's ESCAPE arm gains the same prelude before its existing btnCancel trigger. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/screens/match/views/VPrompt.java | 24 +++---------------- .../src/forge/screens/match/MatchScreen.java | 9 ++++++- .../gamemodes/match/YieldController.java | 20 ++++++++++++++++ 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 804309a94db..875ba2f0e88 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -31,8 +31,6 @@ import forge.game.card.CardView; import forge.game.player.PlayerView; -import forge.gamemodes.match.YieldController; -import forge.gamemodes.match.YieldUpdate; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; @@ -41,7 +39,6 @@ import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; -import forge.player.PlayerControllerHuman; import forge.screens.match.CMatchUI; import forge.screens.match.controllers.CPrompt; import forge.toolbox.FButton; @@ -85,24 +82,9 @@ public void keyPressed(final KeyEvent e) { if (ui != null) { PlayerView local = ui.getCurrentPlayer(); IGameController ctrl = local != null ? ui.getGameController(local) : null; - if (ctrl != null) { - YieldController yc = ctrl.getYieldController(); - boolean hasMarker = yc.getMarker() != null; - boolean hasLegacy = yc.isAutoPassUntilEndOfTurn(); - boolean hasStackYield = yc.isStackYieldActive(); - if (hasMarker) { - ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - } - if (hasLegacy && ctrl instanceof PlayerControllerHuman pch) { - pch.autoPassCancel(); - } - if (hasStackYield) { - ctrl.sendYieldUpdate(new YieldUpdate.SetStackYield(local, false)); - } - if (hasMarker || hasLegacy || hasStackYield) { - ui.refreshYieldUi(local); - return; - } + if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { + ui.refreshYieldUi(local); + return; } } if (btnCancel.isEnabled() && diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index e07016b1f2d..981cf61f230 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -628,13 +628,20 @@ public boolean keyDown(int keyCode) { return true; } return getActivePrompt().getBtnCancel().trigger(); //trigger Cancel if can't trigger OK - case Keys.ESCAPE: + case Keys.ESCAPE: { + PlayerView local = MatchController.instance.getCurrentPlayer(); + IGameController ctrl = local != null ? MatchController.instance.getGameController(local) : null; + if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { + MatchController.instance.refreshYieldUi(local); + return true; + } if (!FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) && !Forge.hasGamepad()) {//bypass check if (getActivePrompt().getBtnCancel().getText().equals(Forge.getLocalizer().getMessage("lblEndTurn"))) { return false; } } return getActivePrompt().getBtnCancel().trigger(); //otherwise trigger Cancel + } case Keys.BACK: return true; //suppress Back button so it's not bumped when trying to press OK or Cancel buttons case Keys.A: //alpha strike on Ctrl+A on Android, A when running on desktop diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index fe15604cb1a..36630dbcd4b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -3,6 +3,7 @@ import forge.game.GameView; import forge.game.phase.PhaseType; import forge.game.player.PlayerView; +import forge.interfaces.IGameController; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; @@ -123,6 +124,25 @@ public void cancelYield() { autoPassUntilEndOfTurn = false; } + /** + * User-initiated ESC: clear marker, legacy autopass, and stack-yield (the three "active" + * yield forms). Persistent per-card auto-yields are unaffected. Marker and stack-yield go + * over the wire via the controller; legacy autopass is local to host PCH. + * + * @return true if any of the three were active and got cleared (caller should refresh UI + * and suppress the ESC fallthrough); false if nothing was active. + */ + public boolean clearActiveYields(PlayerView local, IGameController controller) { + boolean hadMarker = marker != null; + boolean hadLegacy = autoPassUntilEndOfTurn; + boolean hadStackYield = stackYield; + if (!hadMarker && !hadLegacy && !hadStackYield) return false; + if (hadMarker) controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + if (hadLegacy && controller instanceof PlayerControllerHuman pch) pch.autoPassCancel(); + if (hadStackYield) controller.sendYieldUpdate(new YieldUpdate.SetStackYield(local, false)); + return true; + } + public void setMarker(PlayerView phaseOwner, PhaseType phase) { autoPassUntilEndOfTurn = false; if (phaseOwner == null || phase == null) { From 80856fbd97e51650c2ebca6ae2e73d98ba3ff7e9 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:00:17 +0930 Subject: [PATCH 09/21] Use controller.getMatchUI() in VPrompt ESC handler Per tool4ever's review on PR #10555: the ESC handler was reaching for the global CMatchUI.getActive() static when it could just read controller.getMatchUI() off VPrompt's own CPrompt field. The controller-scoped accessor is also more correct - the static lookup could in principle return a different instance than the one this prompt belongs to. Drops the redundant null check that was guarding against the static returning null. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/views/VPrompt.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 875ba2f0e88..6d768074cdc 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -78,14 +78,12 @@ public void setCardView(final CardView card) { @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { - CMatchUI ui = CMatchUI.getActive(); - if (ui != null) { - PlayerView local = ui.getCurrentPlayer(); - IGameController ctrl = local != null ? ui.getGameController(local) : null; - if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { - ui.refreshYieldUi(local); - return; - } + CMatchUI ui = controller.getMatchUI(); + PlayerView local = ui.getCurrentPlayer(); + IGameController ctrl = local != null ? ui.getGameController(local) : null; + if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { + ui.refreshYieldUi(local); + return; } if (btnCancel.isEnabled() && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { From e850a988b7706f282e3af9df1574c99a6c4c45ef Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Thu, 30 Apr 2026 16:46:24 +0200 Subject: [PATCH 10/21] Test android build --- forge-gui-android/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge-gui-android/pom.xml b/forge-gui-android/pom.xml index 0f4336e92e0..1dcfffa9031 100644 --- a/forge-gui-android/pom.xml +++ b/forge-gui-android/pom.xml @@ -715,7 +715,7 @@ apk - + forge-dev-android-${snapshot-version} From 90eee4d81d298cf2e2235634b0928c3dbf9321bf Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Thu, 30 Apr 2026 17:29:31 +0200 Subject: [PATCH 11/21] Start of clean up --- forge-gui-android/pom.xml | 2 +- .../java/forge/screens/match/CMatchUI.java | 21 +- .../forge/screens/match/views/VPrompt.java | 2 +- .../forge/screens/match/views/VStack.java | 2 +- .../forge/screens/match/MatchController.java | 4 +- .../src/forge/screens/match/MatchScreen.java | 2 +- .../forge/screens/match/views/VPrompt.java | 8 +- .../src/forge/screens/match/views/VStack.java | 2 +- forge-gui/res/cardsfolder/c/commit_memory.txt | 2 +- forge-gui/res/cardsfolder/e/echo_of_eons.txt | 2 +- forge-gui/res/cardsfolder/t/timetwister.txt | 2 +- .../gamemodes/match/AbstractGuiGame.java | 6 +- .../gamemodes/match/YieldController.java | 239 ++++++++---------- .../forge/gamemodes/match/YieldUpdate.java | 16 +- .../forge/gamemodes/net/NetworkGuiGame.java | 23 +- .../net/client/NetGameController.java | 21 +- .../java/forge/gui/interfaces/IGuiGame.java | 4 +- .../forge/interfaces/IGameController.java | 12 +- .../java/forge/player/AutoYieldStore.java | 3 +- .../forge/player/PlayerControllerHuman.java | 33 +-- 20 files changed, 173 insertions(+), 233 deletions(-) diff --git a/forge-gui-android/pom.xml b/forge-gui-android/pom.xml index 1dcfffa9031..0f4336e92e0 100644 --- a/forge-gui-android/pom.xml +++ b/forge-gui-android/pom.xml @@ -715,7 +715,7 @@ apk - + forge-dev-android-${snapshot-version} diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 2ab0d1163e7..07f02b32473 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -25,7 +25,6 @@ import java.util.List; import java.util.Map.Entry; import java.util.concurrent.atomic.AtomicReference; - import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JOptionPane; @@ -132,6 +131,7 @@ import forge.view.FView; import forge.view.arcane.CardPanel; import forge.view.arcane.FloatingZone; + import net.miginfocom.layout.LinkHandler; import net.miginfocom.swing.MigLayout; @@ -183,7 +183,7 @@ public CMatchUI() { this.myDocs.put(EDocID.CARD_PICTURE, cDetailPicture.getCPicture().getView()); this.myDocs.put(EDocID.CARD_DETAIL, cDetailPicture.getCDetail().getView()); // only create an ante doc if playing for ante - if (isPreferenceEnabled(FPref.UI_ANTE)) { + if (FModel.getPreferences().getPrefBoolean(FPref.UI_ANTE)) { this.myDocs.put(EDocID.CARD_ANTES, cAntes.getView()); } else { this.myDocs.put(EDocID.CARD_ANTES, null); @@ -203,10 +203,6 @@ private void registerDocs() { } } - private static boolean isPreferenceEnabled(final ForgePreferences.FPref preferenceName) { - return FModel.getPreferences().getPrefBoolean(preferenceName); - } - FScreen getScreen() { return this.screen; } @@ -214,15 +210,6 @@ public boolean isCurrentScreen() { return Singletons.getControl().getCurrentScreen() == this.screen; } - /** Returns the CMatchUI controlling the currently active match screen, or null. */ - public static CMatchUI getActive() { - FScreen current = Singletons.getControl().getCurrentScreen(); - if (current == null || !current.isMatchScreen()) { - return null; - } - return current.getController() instanceof CMatchUI cm ? cm : null; - } - private boolean isInGame() { return getGameView() != null; } @@ -265,7 +252,7 @@ public void refreshYieldUi(final PlayerView player) { } } IGameController controller = getGameController(local); - YieldMarker marker = controller != null ? controller.getYieldController().getMarker() : null; + YieldMarker marker = controller != null ? controller.getYieldController().getAutoPassUntilMarker() : null; if (marker == null) { return; } @@ -1372,7 +1359,7 @@ private void handleYieldMarkerToggle(final PhaseLabel label, final PlayerView ph if (controller == null) { return; } - YieldMarker existing = controller.getYieldController().getMarker(); + YieldMarker existing = controller.getYieldController().getAutoPassUntilMarker(); boolean clickedSameLabel = existing != null && phaseOwner.equals(existing.getPhaseOwner()) && phase == existing.getPhase(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 6d768074cdc..4b1f0917e80 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -81,7 +81,7 @@ public void keyPressed(final KeyEvent e) { CMatchUI ui = controller.getMatchUI(); PlayerView local = ui.getCurrentPlayer(); IGameController ctrl = local != null ? ui.getGameController(local) : null; - if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { + if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { ui.refreshYieldUi(local); return; } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java index bce717ac755..28c656fa98f 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VStack.java @@ -342,7 +342,7 @@ public AbilityMenu(){ jmiYieldToEntireStack.addActionListener(arg0 -> { final PlayerView local = controller.getMatchUI().getCurrentPlayer(); if (local == null) return; - controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.SetStackYield(local, true)); + controller.getMatchUI().getGameController().sendYieldUpdate(new YieldUpdate.StackYield(local, true)); controller.getMatchUI().getGameController().passPriority(); }); add(jmiYieldToEntireStack); diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 97fc6c49ea3..a337b83cf55 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -590,7 +590,7 @@ private void handleYieldMarkerToggle(final VPhaseIndicator.PhaseLabel label, fin if (controller == null) { return; } - YieldMarker existing = controller.getYieldController().getMarker(); + YieldMarker existing = controller.getYieldController().getAutoPassUntilMarker(); boolean clickedSameLabel = existing != null && phaseOwner.equals(existing.getPhaseOwner()) && phase == existing.getPhase(); @@ -745,7 +745,7 @@ public void refreshYieldUi(final PlayerView player) { } } IGameController controller = getGameController(local); - YieldMarker marker = controller != null ? controller.getYieldController().getMarker() : null; + YieldMarker marker = controller != null ? controller.getYieldController().getAutoPassUntilMarker() : null; if (marker == null) { return; } diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 981cf61f230..715dba2df42 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -631,7 +631,7 @@ public boolean keyDown(int keyCode) { case Keys.ESCAPE: { PlayerView local = MatchController.instance.getCurrentPlayer(); IGameController ctrl = local != null ? MatchController.instance.getGameController(local) : null; - if (ctrl != null && ctrl.getYieldController().clearActiveYields(local, ctrl)) { + if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { MatchController.instance.refreshYieldUi(local); return true; } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPrompt.java b/forge-gui-mobile/src/forge/screens/match/views/VPrompt.java index 1c7e0897f6c..2d798ef2ca2 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPrompt.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPrompt.java @@ -40,10 +40,10 @@ public static FSkinColor getForeColor() { private final FButton btnOk, btnCancel; private final MessageLabel lblMessage; private String message; - private CardView card = null ; + private CardView card = null; public void setCardView(final CardView card) { - this.card = card ; + this.card = card; } public VPrompt(String okText, String cancelText, FEventHandler okCommand, FEventHandler cancelCommand) { @@ -121,7 +121,7 @@ public boolean tap(float x, float y, int count) { @Override public boolean fling(float x, float y) { - if ( card != null ) { + if (card != null) { CardZoom.show(card); } return true; @@ -129,7 +129,7 @@ public boolean fling(float x, float y) { @Override public boolean longPress(float x, float y) { - if ( card != null ) { + if (card != null) { CardZoom.show(card); } return true; diff --git a/forge-gui-mobile/src/forge/screens/match/views/VStack.java b/forge-gui-mobile/src/forge/screens/match/views/VStack.java index 4027c275e37..bfc37d27b74 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VStack.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VStack.java @@ -329,7 +329,7 @@ protected void buildMenu() { addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblYieldToEntireStack"), Forge.hdbuttons ? FSkinImage.HDYIELD : FSkinImage.WARNING, e -> { - controller.sendYieldUpdate(new YieldUpdate.SetStackYield(player, true)); + controller.sendYieldUpdate(new YieldUpdate.StackYield(player, true)); controller.passPriority(); })); addItem(new FMenuItem(Forge.getLocalizer().getMessage("lblZoomOrDetails"), e -> CardZoom.show(stackInstance.getSourceCard()))); diff --git a/forge-gui/res/cardsfolder/c/commit_memory.txt b/forge-gui/res/cardsfolder/c/commit_memory.txt index 5cd6e55e4f0..3bf552eac69 100644 --- a/forge-gui/res/cardsfolder/c/commit_memory.txt +++ b/forge-gui/res/cardsfolder/c/commit_memory.txt @@ -12,6 +12,6 @@ Name:Memory ManaCost:4 U U Types:Sorcery K:Aftermath -A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ TimeTwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. +A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ Timetwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. SVar:DBDraw:DB$ Draw | NumCards$ 7 | Defined$ Player | StackDescription$ None Oracle:Aftermath (Cast this spell only from your graveyard. Then exile it.)\nEach player shuffles their hand and graveyard into their library, then draws seven cards. diff --git a/forge-gui/res/cardsfolder/e/echo_of_eons.txt b/forge-gui/res/cardsfolder/e/echo_of_eons.txt index e769eae85f0..5a0f3f543a0 100644 --- a/forge-gui/res/cardsfolder/e/echo_of_eons.txt +++ b/forge-gui/res/cardsfolder/e/echo_of_eons.txt @@ -1,7 +1,7 @@ Name:Echo of Eons ManaCost:4 U U Types:Sorcery -A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ TimeTwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. +A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ Timetwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. SVar:DBDraw:DB$ Draw | NumCards$ 7 | Defined$ Player K:Flashback:2 U Oracle:Each player shuffles their hand and graveyard into their library, then draws seven cards.\nFlashback {2}{U} (You may cast this card from your graveyard for its flashback cost. Then exile it.) diff --git a/forge-gui/res/cardsfolder/t/timetwister.txt b/forge-gui/res/cardsfolder/t/timetwister.txt index 2f4ca6b4aa4..b9bb4f80feb 100644 --- a/forge-gui/res/cardsfolder/t/timetwister.txt +++ b/forge-gui/res/cardsfolder/t/timetwister.txt @@ -1,6 +1,6 @@ Name:Timetwister ManaCost:2 U Types:Sorcery -A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ TimeTwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. +A:SP$ ChangeZoneAll | ChangeType$ Card | Origin$ Hand,Graveyard | Destination$ Library | Shuffle$ True | SubAbility$ DBDraw | UseAllOriginZones$ True | AILogic$ Timetwister | SpellDescription$ Each player shuffles their graveyard and hand into their library, then draws seven cards. SVar:DBDraw:DB$ Draw | NumCards$ 7 | Defined$ Player Oracle:Each player shuffles their hand and graveyard into their library, then draws seven cards. (Then put Timetwister into its owner's graveyard.) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index eec41d6f9b0..69b1c01c969 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -563,9 +563,9 @@ public final void updateAutoPassPrompt() { } Localizer loc = Localizer.getInstance(); final String message; - if (yielding.getMarker() != null) { - message = loc.getMessage("lblYieldingUntilPhaseFmt", yielding.getMarker().getPhase().nameForUi); - } else if (yielding.isStackYieldActive()) { + if (yielding.getAutoPassUntilMarker() != null) { + message = loc.getMessage("lblYieldingUntilPhaseFmt", yielding.getAutoPassUntilMarker().getPhase().nameForUi); + } else if (yielding.autoPassUntilEOT()) { message = loc.getMessage("lblYieldingUntilStackClears"); } else { message = loc.getMessage("lblYieldingUntilEndOfTurn"); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 36630dbcd4b..078b8d96b46 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -19,10 +19,6 @@ import java.util.Set; /** - * Per-PlayerControllerHuman yield state holder. Owns markers, stack-yield, - * autopass-until-end-of-turn, per-card/ability auto-yield, trigger - * decisions, and skip-phase prefs. - * *

Auto-yield state and trigger decisions live in a single {@link AutoYieldStore} * resolved by {@link #activeStore()}: *

    @@ -38,140 +34,68 @@ public class YieldController { private final PlayerControllerHuman owner; - private boolean autoPassUntilEndOfTurn; + private boolean autoPassUntilStackEmpty; + private boolean autoPassUntilEOT; + private YieldMarker autoPassUntilMarker; - private YieldMarker marker; /** Priority has passed through any non-target phase since marker activation. */ private boolean hasLeftMarker; /** Marker was set while priority was already at its target; require a full cycle to fire. */ private boolean activationOnMarker; - /** Survives opponent spells; auto-clears only when stack empties, NOT on cancelYield. */ - private boolean stackYield; - - /** - * Backing store used in cache mode (host PCH for remote, NetGameController on - * client). For host PCH for a local player, {@link #activeStore()} returns the - * LobbyPlayer's persistent store and this field is unused. - */ private final AutoYieldStore localStore = new AutoYieldStore(); - private final Map> skipPhases = new HashMap<>(); public YieldController(PlayerControllerHuman owner) { this.owner = owner; } - public boolean isAutoPassUntilEndOfTurn() { - return autoPassUntilEndOfTurn; + public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { + EnumSet set = skipPhases.get(turnPlayer); + return set != null && set.contains(phase); } - - public void setAutoPassUntilEndOfTurn(boolean active) { - this.autoPassUntilEndOfTurn = active; + public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { + if (turnPlayer == null || phase == null) return; + EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); + if (skip) set.add(phase); + else set.remove(phase); } - public boolean shouldAutoYield() { - if (autoPassUntilEndOfTurn) return true; - - GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; - - if (stackYield) { - if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) { - return true; - } - stackYield = false; - } - - if (marker == null || gv == null) return false; - - PlayerView turnPlayer = gv.getPlayerTurn(); - PhaseType currentPhase = gv.getPhase(); - - boolean inMarkerOwnerTurn = turnPlayer != null && turnPlayer.equals(marker.getPhaseOwner()); - boolean atTarget = inMarkerOwnerTurn && currentPhase == marker.getPhase(); - boolean pastTarget = inMarkerOwnerTurn && currentPhase != null - && marker.getPhase() != null && currentPhase.isAfter(marker.getPhase()); - - boolean shouldFire = hasLeftMarker - && (atTarget || (!activationOnMarker && pastTarget)); - - if (shouldFire) { - clearMarker(); - notifyMarkerCleared(); - return false; - } - if (!atTarget && !hasLeftMarker) { - hasLeftMarker = true; - } - return true; + public boolean autoPassUntilEOT() { + return autoPassUntilStackEmpty; } - - private void notifyMarkerCleared() { - if (owner == null || owner.getGui() == null) return; - PlayerView player = owner.getLocalPlayerView(); - if (player == null) return; - owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(player)); + public YieldMarker getAutoPassUntilMarker() { + return autoPassUntilMarker; } - /** - * Engine-driven cancel matching master's autoPassCancel semantics: clears - * legacy autopass-until-end-of-turn only. Markers and stack-yield survive — - * markers can target phases on future turns (must outlive turn-boundary - * cancellation), and stack-yield must resolve the entire stack including - * post-cancel additions. User-initiated ESC clears all three explicitly. - */ - public void cancelYield() { - autoPassUntilEndOfTurn = false; + public void setAutoPassUntilStackEmpty(boolean active) { + if (active) autoPassUntilEOT = false; + this.autoPassUntilStackEmpty = active; } - - /** - * User-initiated ESC: clear marker, legacy autopass, and stack-yield (the three "active" - * yield forms). Persistent per-card auto-yields are unaffected. Marker and stack-yield go - * over the wire via the controller; legacy autopass is local to host PCH. - * - * @return true if any of the three were active and got cleared (caller should refresh UI - * and suppress the ESC fallthrough); false if nothing was active. - */ - public boolean clearActiveYields(PlayerView local, IGameController controller) { - boolean hadMarker = marker != null; - boolean hadLegacy = autoPassUntilEndOfTurn; - boolean hadStackYield = stackYield; - if (!hadMarker && !hadLegacy && !hadStackYield) return false; - if (hadMarker) controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - if (hadLegacy && controller instanceof PlayerControllerHuman pch) pch.autoPassCancel(); - if (hadStackYield) controller.sendYieldUpdate(new YieldUpdate.SetStackYield(local, false)); - return true; + public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { + this.autoPassUntilEOT = active; } public void setMarker(PlayerView phaseOwner, PhaseType phase) { - autoPassUntilEndOfTurn = false; + autoPassUntilEOT = false; if (phaseOwner == null || phase == null) { clearMarker(); return; } - marker = new YieldMarker(phaseOwner, phase); + autoPassUntilMarker = new YieldMarker(phaseOwner, phase); // Activating at-or-past target on the owner's current turn must wait for next turn's // occurrence; otherwise pastTarget would fire and clear the marker on the same turn. - boolean atOrPast = isPriorityAtOrPastMarker(marker); + boolean atOrPast = isPriorityAtOrPastMarker(autoPassUntilMarker); hasLeftMarker = !atOrPast; activationOnMarker = atOrPast; } public void clearMarker() { - marker = null; + autoPassUntilMarker = null; hasLeftMarker = false; activationOnMarker = false; } - public YieldMarker getMarker() { return marker; } - - public void setStackYield(boolean active) { - if (active) autoPassUntilEndOfTurn = false; - this.stackYield = active; - } - - public boolean isStackYieldActive() { return stackYield; } - private boolean isPriorityAtOrPastMarker(YieldMarker m) { if (m == null || owner == null || owner.getGui() == null) return false; GameView gv = owner.getGui().getGameView(); @@ -183,55 +107,75 @@ private boolean isPriorityAtOrPastMarker(YieldMarker m) { return phase == m.getPhase() || phase.isAfter(m.getPhase()); } - public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { - if (turnPlayer == null || phase == null) return; - EnumSet set = skipPhases.computeIfAbsent(turnPlayer, k -> EnumSet.noneOf(PhaseType.class)); - if (skip) set.add(phase); - else set.remove(phase); + /** + * User-initiated ESC: clear marker, legacy autopass, and stack-yield (the three "active" + * yield forms). Persistent per-card auto-yields are unaffected. Marker and stack-yield go + * over the wire via the controller; legacy autopass is local to host PCH. + * + * @return true if any of the three were active and got cleared (caller should refresh UI + * and suppress the ESC fallthrough); false if nothing was active. + */ + public boolean cancelGenericYields(PlayerView local, IGameController controller) { + boolean hadMarker = autoPassUntilMarker != null; + boolean hadLegacy = autoPassUntilEOT; + boolean hadStackYield = autoPassUntilStackEmpty; + if (!hadMarker && !hadLegacy && !hadStackYield) return false; + if (hadMarker) controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + if (hadLegacy && controller instanceof PlayerControllerHuman pch) pch.autoPassCancel(); + if (hadStackYield) controller.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + return true; } - public boolean isSkippingPhase(PlayerView turnPlayer, PhaseType phase) { - EnumSet set = skipPhases.get(turnPlayer); - return set != null && set.contains(phase); - } + public boolean shouldAutoYield() { + if (autoPassUntilEOT) return true; - // ---- Auto-yield (per-card/ability) and trigger decisions ---- + GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; - /** - * Cache mode (host PCH for remote, or NetGameController) → {@link #localStore}. - * Tier-aware mode (host PCH for local player) → LobbyPlayer's persistent store. - */ - private AutoYieldStore activeStore() { - if (owner != null && !owner.isRemoteClient()) { - return ((LobbyPlayerHuman) owner.getLobbyPlayer()).getYieldStore(); + if (autoPassUntilStackEmpty) { + if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) { + return true; + } + autoPassUntilStackEmpty = false; } - return localStore; - } - /** True if FPref tier/install logic applies (local user context). False for the host's remote-cache mode. */ - private boolean tierAware() { - return owner == null || !owner.isRemoteClient(); - } + if (autoPassUntilMarker == null || gv == null) return false; - private static boolean activeModeIsInstall() { - return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals( - FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); - } + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType currentPhase = gv.getPhase(); - private static AutoYieldStore.Tier activeTier() { - String mode = FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE); - if (ForgeConstants.AUTO_YIELD_PER_CARD.equals(mode)) return AutoYieldStore.Tier.GAME; - if (ForgeConstants.AUTO_YIELD_PER_ABILITY_SESSION.equals(mode)) return AutoYieldStore.Tier.SESSION; - return AutoYieldStore.Tier.MATCH; + boolean inMarkerOwnerTurn = turnPlayer != null && turnPlayer.equals(autoPassUntilMarker.getPhaseOwner()); + boolean atTarget = inMarkerOwnerTurn && currentPhase == autoPassUntilMarker.getPhase(); + boolean pastTarget = inMarkerOwnerTurn && currentPhase != null + && autoPassUntilMarker.getPhase() != null && currentPhase.isAfter(autoPassUntilMarker.getPhase()); + + boolean shouldFire = hasLeftMarker + && (atTarget || (!activationOnMarker && pastTarget)); + + if (shouldFire) { + clearMarker(); + if (owner != null && owner.getGui() != null) { + PlayerView player = owner.getLocalPlayerView(); + if (player != null) { + owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(player)); + } + } + return false; + } + if (!atTarget && !hasLeftMarker) { + hasLeftMarker = true; + } + return true; } + // ---- Auto-yield (per-card/ability) and trigger decisions ---- + public boolean shouldAutoYield(String key) { AutoYieldStore store = activeStore(); if (store.isDisabled()) return false; if (!tierAware()) { // Cache: keys stored at storageKey shape (full or stripped). Check both. return store.shouldYield(AutoYieldStore.Tier.GAME, key) - || store.shouldYield(AutoYieldStore.Tier.GAME, AutoYieldStore.abilitySuffix(key)); + || store.shouldYield(AutoYieldStore.Tier.GAME, AutoYieldStore.abilitySuffix(key)); } if (activeModeIsInstall()) { return PersistentYieldStore.get().contains(AutoYieldStore.abilitySuffix(key)); @@ -253,6 +197,34 @@ public String setShouldAutoYield(String key, boolean autoYield, boolean abilityS return storageKey; } + /** + * Cache mode (host PCH for remote, or NetGameController) → {@link #localStore}. + * Tier-aware mode (host PCH for local player) → LobbyPlayer's persistent store. + */ + private AutoYieldStore activeStore() { + if (owner != null && !owner.isRemoteClient()) { + return ((LobbyPlayerHuman) owner.getLobbyPlayer()).getYieldStore(); + } + return localStore; + } + + /** True if FPref tier/install logic applies (local user context). False for the host's remote-cache mode. */ + private boolean tierAware() { + return owner == null || !owner.isRemoteClient(); + } + + private static boolean activeModeIsInstall() { + return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals( + FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); + } + + private static AutoYieldStore.Tier activeTier() { + String mode = FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE); + if (ForgeConstants.AUTO_YIELD_PER_CARD.equals(mode)) return AutoYieldStore.Tier.GAME; + if (ForgeConstants.AUTO_YIELD_PER_ABILITY_SESSION.equals(mode)) return AutoYieldStore.Tier.SESSION; + return AutoYieldStore.Tier.MATCH; + } + /** Cache-mode write of a wire-received update. Storage key is already at the right shape. */ public void applyAutoYieldFromWire(String storageKey, boolean active) { activeStore().setYield(AutoYieldStore.Tier.GAME, storageKey, active); @@ -277,7 +249,6 @@ public void clearAutoYields() { public boolean getDisableAutoYields() { return activeStore().isDisabled(); } - public void setDisableAutoYields(boolean disable) { activeStore().setDisabled(disable); } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index a602d9c212b..5cb5aa6ae69 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -17,23 +17,23 @@ public sealed interface YieldUpdate extends Serializable permits YieldUpdate.SetMarker, YieldUpdate.ClearMarker, - YieldUpdate.SetStackYield, - YieldUpdate.SetTriggerDecision, - YieldUpdate.SetCardAutoYield, - YieldUpdate.SetSkipPhase, + YieldUpdate.StackYield, + YieldUpdate.TriggerDecision, + YieldUpdate.CardAutoYield, + YieldUpdate.SkipPhase, YieldUpdate.SeedFromClient { record SetMarker(PlayerView phaseOwner, PhaseType phase) implements YieldUpdate {} record ClearMarker(PlayerView player) implements YieldUpdate {} - record SetStackYield(PlayerView player, boolean active) implements YieldUpdate {} + record StackYield(PlayerView player, boolean active) implements YieldUpdate {} - record SetTriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) implements YieldUpdate {} + record TriggerDecision(int trigId, AutoYieldStore.TriggerDecision decision) implements YieldUpdate {} - record SetCardAutoYield(String cardKey, boolean active, boolean abilityScope) implements YieldUpdate {} + record CardAutoYield(String cardKey, boolean active, boolean abilityScope) implements YieldUpdate {} - record SetSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} + record SkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} /** Atomic snapshot of client's persistent yield state. Sent by client at game start and reconnection only - not host->client. */ record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index 2f885c2aa43..fe307663637 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -610,7 +610,7 @@ private void logChecksumDetails(GameView gameView, DeltaPacket packet) { @Override public void applyYieldUpdate(YieldUpdate update) { PlayerView player = getCurrentPlayer(); - IGameController controller = (player != null) ? getGameController(player) : null; + IGameController controller = player != null ? getGameController(player) : null; if (controller != null) { controller.applyYieldUpdate(update); } @@ -618,7 +618,7 @@ public void applyYieldUpdate(YieldUpdate update) { if (player != null && (update instanceof YieldUpdate.SetMarker || update instanceof YieldUpdate.ClearMarker - || update instanceof YieldUpdate.SetStackYield)) { + || update instanceof YieldUpdate.StackYield)) { refreshYieldUi(player); } } @@ -643,16 +643,7 @@ protected final void pushSkipPhaseToControllers(final PlayerView player, final P * during play flow as individual YieldUpdate deltas. */ protected final void seedYieldStateOnHost() { - Map> skipPhases = collectSkipPhases(); - for (final IGameController c : getOriginalGameControllers()) { - if (c instanceof NetGameController nc) { - nc.seedYieldStateOnHost(skipPhases); - } - } - } - - private Map> collectSkipPhases() { - Map> out = new HashMap<>(); + Map> skipPhases = new HashMap<>(); for (PlayerView p : getGameView().getPlayers()) { EnumSet set = EnumSet.noneOf(PhaseType.class); for (PhaseType ph : PhaseType.values()) { @@ -661,10 +652,14 @@ private Map> collectSkipPhases() { } } if (!set.isEmpty()) { - out.put(p, set); + skipPhases.put(p, set); + } + } + for (final IGameController c : getOriginalGameControllers()) { + if (c instanceof NetGameController nc) { + nc.seedYieldStateOnHost(skipPhases); } } - return out; } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 4e246df1c00..1454170538c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -150,7 +150,7 @@ public boolean shouldAutoYield(final String key) { public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { String storageKey = yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.SetCardAutoYield(storageKey, autoYield, isAbilityScope)); + new YieldUpdate.CardAutoYield(storageKey, autoYield, isAbilityScope)); } @Override @@ -183,21 +183,19 @@ public boolean shouldAlwaysDeclineTrigger(final int trigger) { public void setShouldAlwaysAcceptTrigger(final int trigger) { yieldController.setAlwaysAcceptTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); + new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); } @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { yieldController.setAlwaysDeclineTrigger(trigger); - send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE)); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE)); } @Override public void setShouldAlwaysAskTrigger(final int trigger) { yieldController.setAlwaysAskTrigger(trigger); - send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.SetTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK)); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK)); } /** @@ -205,13 +203,12 @@ public void setShouldAlwaysAskTrigger(final int trigger) { * GUI-loaded skip-phase prefs and ship it to the host in one wire message. */ public void seedYieldStateOnHost(Map> skipPhases) { - send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); } public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); - send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SetSkipPhase(turnPlayer, phase, shouldSkip)); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SkipPhase(turnPlayer, phase, shouldSkip)); } @Override @@ -220,9 +217,9 @@ public void applyYieldUpdate(final YieldUpdate update) { yieldController.setMarker(u.phaseOwner(), u.phase()); } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.SetStackYield u) { - yieldController.setStackYield(u.active()); - } else if (update instanceof YieldUpdate.SetSkipPhase u) { + } else if (update instanceof YieldUpdate.StackYield u) { + yieldController.setAutoPassUntilStackEmpty(u.active()); + } else if (update instanceof YieldUpdate.SkipPhase u) { yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } // SetCardAutoYield/SetTriggerDecision: server never pushes these to the client, and diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 2c71ad178cd..7b823c1e314 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -275,8 +275,8 @@ default List many(final String title, final String topCaption, final int /** Apply a yield update envelope (server->client direction). */ void applyYieldUpdate(YieldUpdate update); - /** Repaint marker chevron / stack-yield UI for the given player. Default no-op. */ - default void refreshYieldUi(PlayerView player) { /* impl per platform */ } + /** Repaint marker chevron / stack-yield UI for the given player. */ + default void refreshYieldUi(PlayerView player) {} /** Returns true if this game instance is a network game. */ boolean isNetGame(); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 811fcdb4ae8..4ec02f6267e 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -26,10 +26,6 @@ public interface IGameController { void selectButtonCancel(); - void passPriority(); - - void passPriorityUntilEndOfTurn(); - void selectPlayer(PlayerView playerView, ITriggerEvent triggerEvent); boolean selectCard(CardView cardView, List otherCardViewsToSelect, ITriggerEvent triggerEvent); @@ -54,7 +50,10 @@ public interface IGameController { */ void requestResync(); - // --- Auto-yield preferences (per-player) --- + void passPriority(); + void passPriorityUntilEndOfTurn(); + + // Auto-yield preferences boolean shouldAutoYield(String key); /** * @param isAbilityScope true if {@code key} is an ability suffix (Per Ability * modes); @@ -67,7 +66,7 @@ public interface IGameController { boolean getDisableAutoYields(); void setDisableAutoYields(boolean disable); - // --- Trigger accept/decline preferences (per-player) --- + // Trigger accept/decline preferences boolean shouldAlwaysAcceptTrigger(int trigger); boolean shouldAlwaysDeclineTrigger(int trigger); void setShouldAlwaysAcceptTrigger(int trigger); @@ -85,6 +84,5 @@ default void sendYieldUpdate(YieldUpdate update) { applyYieldUpdate(update); } - /** Access this controller's YieldController. */ YieldController getYieldController(); } diff --git a/forge-gui/src/main/java/forge/player/AutoYieldStore.java b/forge-gui/src/main/java/forge/player/AutoYieldStore.java index 11ead91487f..29855241e0a 100644 --- a/forge-gui/src/main/java/forge/player/AutoYieldStore.java +++ b/forge-gui/src/main/java/forge/player/AutoYieldStore.java @@ -33,8 +33,7 @@ public void setYield(Tier tier, String key, boolean autoYield) { public void setDisabled(boolean disabled) { this.disabled = disabled; } public TriggerDecision getTriggerDecision(int triggerId) { - TriggerDecision d = triggerDecisions.get(triggerId); - return d == null ? TriggerDecision.ASK : d; + return triggerDecisions.getOrDefault(triggerId, TriggerDecision.ASK); } public void setTriggerDecision(int triggerId, TriggerDecision decision) { diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 74ef523f01b..6b64e1ca4c6 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1526,8 +1526,7 @@ public List chooseSpellAbilityToPlay() { } if (stack.isEmpty()) { - if (isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), - getGame().getPhaseHandler().getPhase())) { + if (isUiSetToSkipPhase(getGame().getPhaseHandler().getPlayerTurn().getView(), getGame().getPhaseHandler().getPhase())) { netLog.trace("Returning null (skipPhase) for player {}", player.getName()); return null; // avoid prompt for input if stack is empty and // player is set to skip the current phase @@ -3347,7 +3346,7 @@ public boolean mayAutoPass() { } public void autoPassUntilEndOfTurn() { - yieldController.setAutoPassUntilEndOfTurn(true); + yieldController.setAutoPassUntilEOTWithoutInterruptions(true); if (getGui() != null) { getGui().updateAutoPassPrompt(); } @@ -3355,10 +3354,10 @@ public void autoPassUntilEndOfTurn() { @Override public void autoPassCancel() { - if (!yieldController.shouldAutoYield()) { + if (!mayAutoPass()) { return; } - yieldController.cancelYield(); + yieldController.setAutoPassUntilEOTWithoutInterruptions(false); if (getGui() != null) { PlayerView playerView = getLocalPlayerView(); getGui().showPromptMessage(playerView, ""); @@ -3492,7 +3491,6 @@ public boolean isRemoteClient() { public boolean shouldAutoYield(final String key) { return yieldController.shouldAutoYield(key); } - @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); @@ -3512,7 +3510,6 @@ public void clearAutoYields() { public boolean getDisableAutoYields() { return yieldController.getDisableAutoYields(); } - @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); @@ -3522,7 +3519,6 @@ public void setDisableAutoYields(final boolean disable) { public boolean shouldAlwaysAcceptTrigger(final int trigger) { return yieldController.shouldAlwaysAcceptTrigger(trigger); } - @Override public boolean shouldAlwaysDeclineTrigger(final int trigger) { return yieldController.shouldAlwaysDeclineTrigger(trigger); @@ -3533,24 +3529,21 @@ public void setShouldAlwaysAcceptTrigger(final int trigger) { yieldController.setAlwaysAcceptTrigger(trigger); if (isPromptingForTrigger(trigger)) selectButtonOk(); } - @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { yieldController.setAlwaysDeclineTrigger(trigger); if (isPromptingForTrigger(trigger)) selectButtonCancel(); } - + @Override + public void setShouldAlwaysAskTrigger(final int trigger) { + yieldController.setAlwaysAskTrigger(trigger); + } private boolean isPromptingForTrigger(final int trigger) { if (!(inputQueue.getInput() instanceof InputConfirm)) return false; final SpellAbilityStackInstance top = getGame().getStack().peek(); return top != null && top.isStateTrigger(trigger); } - @Override - public void setShouldAlwaysAskTrigger(final int trigger) { - yieldController.setAlwaysAskTrigger(trigger); - } - public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType phase) { if (isRemoteClient()) { return yieldController.isSkippingPhase(turnPlayer, phase); @@ -3564,13 +3557,13 @@ public void applyYieldUpdate(final YieldUpdate update) { yieldController.setMarker(u.phaseOwner(), u.phase()); } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); - } else if (update instanceof YieldUpdate.SetStackYield u) { - yieldController.setStackYield(u.active()); - } else if (update instanceof YieldUpdate.SetTriggerDecision u) { + } else if (update instanceof YieldUpdate.StackYield u) { + yieldController.setAutoPassUntilStackEmpty(u.active()); + } else if (update instanceof YieldUpdate.TriggerDecision u) { yieldController.setTriggerDecision(u.trigId(), u.decision()); - } else if (update instanceof YieldUpdate.SetCardAutoYield u) { + } else if (update instanceof YieldUpdate.CardAutoYield u) { yieldController.applyAutoYieldFromWire(u.cardKey(), u.active()); - } else if (update instanceof YieldUpdate.SetSkipPhase u) { + } else if (update instanceof YieldUpdate.SkipPhase u) { yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } else if (update instanceof YieldUpdate.SeedFromClient u) { yieldController.applyClientSeed(u.snapshot()); From 0a0d70450a164778283480e0e4e38718b3dd2b62 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 1 May 2026 06:08:08 +0930 Subject: [PATCH 12/21] =?UTF-8?q?Drop=20server=E2=86=92client=20applyYield?= =?UTF-8?q?Update;=20client=20derives=20marker=20fire=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #10555 review (tool4ever, r3168957452): the server→client side of the unified yield envelope only ever carried ClearMarker, and that's derivable on the client from cached marker + phase events. Marker-fire detection moves into YieldController.checkAndClearMarker(GameView) — pure derivation called both from the host's shouldAutoYield priority loop (so the host stops auto-passing promptly at the target phase) and from an EDT phase-event listener (so the chevron + auto-pass prompt refresh on both host and remote client without a wire round-trip). Wire surface drops from 2 ProtocolMethod entries to 1: removes ProtocolMethod.applyYieldUpdate, IGuiGame.applyYieldUpdate, and the RemoteClientGuiGame/NetworkGuiGame overrides; AbstractGuiGame.applyYieldUpdate becomes checkMarkerAutoClear. YieldUpdate envelope itself stays as the unified shape for client→host messages. Marker mutators synchronized. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 2 + .../forge/screens/match/MatchController.java | 2 + .../gamemodes/match/AbstractGuiGame.java | 28 +++++++++---- .../gamemodes/match/YieldController.java | 42 ++++++++----------- .../forge/gamemodes/net/NetworkGuiGame.java | 17 -------- .../forge/gamemodes/net/ProtocolMethod.java | 1 - .../net/client/NetGameController.java | 16 +++---- .../net/server/RemoteClientGuiGame.java | 6 --- .../java/forge/gui/interfaces/IGuiGame.java | 4 -- 9 files changed, 48 insertions(+), 70 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 07f02b32473..7152758abef 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -851,6 +851,8 @@ public void updatePhase(boolean saveState) { if (openAbilityMenu != null) { //ensure ability menu can't remain open between phases openAbilityMenu.setVisible(false); } + + checkMarkerAutoClear(); } @Override diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index a337b83cf55..03831c84bfe 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -281,6 +281,8 @@ public IPaperCard getPaperCard(final String cardName, final String setCode, fina } catch (Exception e) { } } + + checkMarkerAutoClear(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index 69b1c01c969..295a9a34389 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -803,14 +803,26 @@ public void applyDelta(DeltaPacket packet) { // No-op for local games - network implementation is in NetworkGuiGame } - @Override - public void applyYieldUpdate(YieldUpdate update) { - // Default: route to current player's controller. Network impl in NetworkGuiGame. - PlayerView player = getCurrentPlayer(); - if (player == null) return; - IGameController controller = getGameController(player); - if (controller != null) { - controller.applyYieldUpdate(update); + /** + * Phase-change derivation: refresh chevron + auto-pass prompt when a controller's marker + * has cleared since the last check. The clearing itself may have happened either here + * (this listener calling {@link YieldController#checkAndClearMarker}) or in the host's + * {@link YieldController#shouldAutoYield} priority loop — we just detect the transition. + */ + public final void checkMarkerAutoClear() { + GameView gv = getGameView(); + if (gv == null) return; + boolean any = false; + for (Map.Entry e : gameControllers.entrySet()) { + YieldController yc = e.getValue().getYieldController(); + if (yc == null) continue; + boolean hadMarker = yc.getAutoPassUntilMarker() != null; + yc.checkAndClearMarker(gv); + if (hadMarker && yc.getAutoPassUntilMarker() == null) { + refreshYieldUi(e.getKey()); + any = true; + } } + if (any) updateAutoPassPrompt(); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 078b8d96b46..38896dba3f1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -76,7 +76,7 @@ public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { this.autoPassUntilEOT = active; } - public void setMarker(PlayerView phaseOwner, PhaseType phase) { + public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase) { autoPassUntilEOT = false; if (phaseOwner == null || phase == null) { clearMarker(); @@ -90,7 +90,7 @@ public void setMarker(PlayerView phaseOwner, PhaseType phase) { activationOnMarker = atOrPast; } - public void clearMarker() { + public synchronized void clearMarker() { autoPassUntilMarker = null; hasLeftMarker = false; activationOnMarker = false; @@ -128,43 +128,37 @@ public boolean cancelGenericYields(PlayerView local, IGameController controller) public boolean shouldAutoYield() { if (autoPassUntilEOT) return true; - GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; - if (autoPassUntilStackEmpty) { - if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) { - return true; - } + if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) return true; autoPassUntilStackEmpty = false; } + checkAndClearMarker(gv); + return autoPassUntilMarker != null; + } + /** + * Pure derivation: evaluate fire conditions against the supplied GameView and clear the + * marker if it should fire. Both the host's priority loop and the GUI's phase-event + * listener call this; whichever runs first wins, the other no-ops on null marker. + * + * @return true if this call cleared the marker (caller should refresh UI). + */ + public synchronized boolean checkAndClearMarker(GameView gv) { if (autoPassUntilMarker == null || gv == null) return false; - PlayerView turnPlayer = gv.getPlayerTurn(); PhaseType currentPhase = gv.getPhase(); - boolean inMarkerOwnerTurn = turnPlayer != null && turnPlayer.equals(autoPassUntilMarker.getPhaseOwner()); boolean atTarget = inMarkerOwnerTurn && currentPhase == autoPassUntilMarker.getPhase(); boolean pastTarget = inMarkerOwnerTurn && currentPhase != null && autoPassUntilMarker.getPhase() != null && currentPhase.isAfter(autoPassUntilMarker.getPhase()); - - boolean shouldFire = hasLeftMarker - && (atTarget || (!activationOnMarker && pastTarget)); - + boolean shouldFire = hasLeftMarker && (atTarget || (!activationOnMarker && pastTarget)); if (shouldFire) { clearMarker(); - if (owner != null && owner.getGui() != null) { - PlayerView player = owner.getLocalPlayerView(); - if (player != null) { - owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(player)); - } - } - return false; - } - if (!atTarget && !hasLeftMarker) { - hasLeftMarker = true; + return true; } - return true; + if (!atTarget && !hasLeftMarker) hasLeftMarker = true; + return false; } // ---- Auto-yield (per-card/ability) and trigger decisions ---- diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index fe307663637..d8842f6033e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -9,7 +9,6 @@ import forge.game.player.PlayerView; import forge.game.zone.ZoneType; import forge.gamemodes.match.AbstractGuiGame; -import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.client.NetGameController; import java.util.EnumSet; import java.util.HashMap; @@ -607,22 +606,6 @@ private void logChecksumDetails(GameView gameView, DeltaPacket packet) { NetworkChecksumUtil.computeChecksumBreakdown(gameView.getTurn(), phaseOrdinal, gameView)); } - @Override - public void applyYieldUpdate(YieldUpdate update) { - PlayerView player = getCurrentPlayer(); - IGameController controller = player != null ? getGameController(player) : null; - if (controller != null) { - controller.applyYieldUpdate(update); - } - // Repaint UI if marker/stack-yield state changed. - if (player != null - && (update instanceof YieldUpdate.SetMarker - || update instanceof YieldUpdate.ClearMarker - || update instanceof YieldUpdate.StackYield)) { - refreshYieldUi(player); - } - } - protected final void pushSkipPhaseToControllers(final PlayerView player, final PhaseType phase) { // Mind-slave AND-combines master+controlled rows, so a master toggle invalidates dependents for (final PlayerView p : getGameView().getPlayers()) { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index 3ac2d4e09f9..8aab100bf59 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -74,7 +74,6 @@ public enum ProtocolMethod implements IHasForgeLog { showWaitingTimer (Mode.SERVER, Void.TYPE, PlayerView.class, String.class), setHighlighted (Mode.SERVER, Void.TYPE, GameEntityView.class, Boolean.TYPE), applyDelta (Mode.SERVER, Void.TYPE, DeltaPacket.class), - applyYieldUpdate (Mode.SERVER, Void.TYPE, YieldUpdate.class), // Client -> Server // Note: these should all return void, to avoid awkward situations in diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 1454170538c..6d2c395d9ed 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -213,26 +213,22 @@ public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType ph @Override public void applyYieldUpdate(final YieldUpdate update) { + // Local self-apply for the three cases that route through sendYieldUpdate + // (marker/stack-yield user actions). Other cases write directly via dedicated + // setters above (SetCardAutoYield, TriggerDecision, SkipPhase, SeedFromClient). if (update instanceof YieldUpdate.SetMarker u) { yieldController.setMarker(u.phaseOwner(), u.phase()); } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); } else if (update instanceof YieldUpdate.StackYield u) { yieldController.setAutoPassUntilStackEmpty(u.active()); - } else if (update instanceof YieldUpdate.SkipPhase u) { - yieldController.setSkipPhase(u.turnPlayer(), u.phase(), u.skip()); } - // SetCardAutoYield/SetTriggerDecision: server never pushes these to the client, and - // user-initiated setters write directly to yieldController + send wire — not via - // sendYieldUpdate's local-apply path. SeedFromClient: no-op on client side. } /** - * User-initiated yield update from local UI: apply to local cache for - * immediate UI response AND ship to host so the authoritative YieldController - * stays in sync. The default IGameController.sendYieldUpdate just calls - * applyYieldUpdate (host-only), which would silently drop the wire send - * for remote clients. + * Remote client's outbound path for user-initiated yield actions (right-click + * marker, ESC, "yield to entire stack" menu): mutate local cache for immediate + * UI response and ship to host. */ @Override public void sendYieldUpdate(final YieldUpdate update) { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index f4c62a1a45c..f9193b422e2 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -13,7 +13,6 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; -import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.net.DeltaPacket; import forge.gamemodes.net.GameProtocolSender; @@ -120,11 +119,6 @@ private void send(final ProtocolMethod method, final Object... args) { sender.send(method, args); } - @Override - public void applyYieldUpdate(final YieldUpdate update) { - send(ProtocolMethod.applyYieldUpdate, update); - } - private T sendAndWait(final ProtocolMethod method, final Object... args) { if (paused) { return null; } flushPendingEvents(); diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 7b823c1e314..7a8dfbbcbc3 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -15,7 +15,6 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; -import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.match.input.InputConfirm; import forge.gamemodes.net.DeltaPacket; import forge.gui.control.PlaybackSpeed; @@ -272,9 +271,6 @@ default List many(final String title, final String topCaption, final int */ void applyDelta(DeltaPacket packet); - /** Apply a yield update envelope (server->client direction). */ - void applyYieldUpdate(YieldUpdate update); - /** Repaint marker chevron / stack-yield UI for the given player. */ default void refreshYieldUi(PlayerView player) {} From f3a25ea584ae1f3b4be7e406c5d175beb8a3a6cf Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 1 May 2026 06:10:27 +0930 Subject: [PATCH 13/21] Drop dead skip-phase cache write in NetGameController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #10555 review (tool4ever, r3169048394): the client-side yieldController.setSkipPhase write in setUiShouldSkipPhase is unread — the client GUI checks PhaseLabel state directly, and the host's auto-pass reads from the host PCH's own yieldController. Only the wire send to host is load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/gamemodes/net/client/NetGameController.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 6d2c395d9ed..26eb6a93973 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -207,15 +207,13 @@ public void seedYieldStateOnHost(Map> skipPhases) } public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { - yieldController.setSkipPhase(turnPlayer, phase, shouldSkip); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SkipPhase(turnPlayer, phase, shouldSkip)); } @Override public void applyYieldUpdate(final YieldUpdate update) { - // Local self-apply for the three cases that route through sendYieldUpdate - // (marker/stack-yield user actions). Other cases write directly via dedicated - // setters above (SetCardAutoYield, TriggerDecision, SkipPhase, SeedFromClient). + // Local self-apply for marker/stack-yield user actions that route through + // sendYieldUpdate. Other cases dispatch via dedicated setters above. if (update instanceof YieldUpdate.SetMarker u) { yieldController.setMarker(u.phaseOwner(), u.phase()); } else if (update instanceof YieldUpdate.ClearMarker) { From bba8b0cdf56c2b11e2f246eb29fd6a80fd964957 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 1 May 2026 06:13:21 +0930 Subject: [PATCH 14/21] ESC: contextual cancel takes precedence over yield clear VPrompt (desktop) and MatchScreen (mobile) ESC handlers were clearing active yields before invoking the prompt's cancel button. If a trigger confirm or other input prompt was open, ESC silently dropped the yield and left the prompt waiting -- breaking the user's "ESC = No / cancel this action" expectation. Flip the precedence: if btnCancel is in a contextually meaningful state (enabled and not the no-op "End Turn" label with UI_ALLOW_ESC_TO_END_TURN off), ESC clicks it and returns. Only when ESC would otherwise be a no-op does it fall back to clearing marker / legacy autopass / stack-yield. Net result is a strict superset of master ESC semantics, plus yield-clear in the slots where master already did nothing. Addresses review feedback on PR #10555. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/forge/screens/match/views/VPrompt.java | 10 +++++----- .../src/forge/screens/match/MatchScreen.java | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 4b1f0917e80..65e9a1f11c5 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -78,16 +78,16 @@ public void setCardView(final CardView card) { @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + if (btnCancel.isEnabled() && + (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { + btnCancel.doClick(); + return; + } CMatchUI ui = controller.getMatchUI(); PlayerView local = ui.getCurrentPlayer(); IGameController ctrl = local != null ? ui.getGameController(local) : null; if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { ui.refreshYieldUi(local); - return; - } - if (btnCancel.isEnabled() && - (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { - btnCancel.doClick(); } } } diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 715dba2df42..3b2e51381f5 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -629,18 +629,18 @@ public boolean keyDown(int keyCode) { } return getActivePrompt().getBtnCancel().trigger(); //trigger Cancel if can't trigger OK case Keys.ESCAPE: { + boolean cancelEligible = FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || Forge.hasGamepad() + || !getActivePrompt().getBtnCancel().getText().equals(Forge.getLocalizer().getMessage("lblEndTurn")); + if (cancelEligible) { + return getActivePrompt().getBtnCancel().trigger(); + } PlayerView local = MatchController.instance.getCurrentPlayer(); IGameController ctrl = local != null ? MatchController.instance.getGameController(local) : null; if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { MatchController.instance.refreshYieldUi(local); return true; } - if (!FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) && !Forge.hasGamepad()) {//bypass check - if (getActivePrompt().getBtnCancel().getText().equals(Forge.getLocalizer().getMessage("lblEndTurn"))) { - return false; - } - } - return getActivePrompt().getBtnCancel().trigger(); //otherwise trigger Cancel + return false; } case Keys.BACK: return true; //suppress Back button so it's not bumped when trying to press OK or Cancel buttons From 05a5615e240ff4dd9fdae632d8c4430e76511df1 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 1 May 2026 06:42:04 +0930 Subject: [PATCH 15/21] Fix marker chevron bugs surfaced by network testing of round-2 changes Host's local chevron stayed drawn after the priority loop fired the marker: shouldAutoYield's checkAndClearMarker call discarded its return value. Now refreshYieldUi runs when the priority-loop path actually clears the marker; the EDT phase-event listener still handles the race-win case. Setting a marker on a past phase from the remote client never showed the chevron: NetGameController constructs YieldController with a null owner, so setMarker's at-or-past check returned false, activationOnMarker defaulted to false, and the next phase event made checkAndClearMarker fire prematurely. Pass the at-or-past flag via SetMarker (computed at click time by the UI), so client cache and host PCH initialize identically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 4 ++- .../forge/screens/match/MatchController.java | 4 ++- .../gamemodes/match/YieldController.java | 30 +++++++++++-------- .../forge/gamemodes/match/YieldUpdate.java | 3 +- .../net/client/NetGameController.java | 2 +- .../forge/player/PlayerControllerHuman.java | 2 +- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 7152758abef..d14cb831924 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -66,6 +66,7 @@ import forge.game.spellability.StackItemView; import forge.game.zone.ZoneType; import forge.util.IHasForgeLog; +import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldMarker; import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; @@ -1372,7 +1373,8 @@ private void handleYieldMarkerToggle(final PhaseLabel label, final PlayerView ph // so the marker can fire (skip-phase pref + marker would skip past). label.setEnabled(true); label.repaintOnlyThisLabel(); - controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase)); + boolean atOrPast = YieldController.isPriorityAtOrPastMarker(getGameView(), phaseOwner, phase); + controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase, atOrPast)); // Pass current priority so the marker takes effect immediately. controller.selectButtonOk(); } diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 03831c84bfe..028118b4fc5 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -38,6 +38,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldController; import forge.gamemodes.match.YieldMarker; import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; @@ -601,7 +602,8 @@ private void handleYieldMarkerToggle(final VPhaseIndicator.PhaseLabel label, fin } else { // Setting a marker implies we want to stop here — un-skip the cell so the marker can fire. label.setStopAtPhase(true); - controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase)); + boolean atOrPast = YieldController.isPriorityAtOrPastMarker(getGameView(), phaseOwner, phase); + controller.sendYieldUpdate(new YieldUpdate.SetMarker(phaseOwner, phase, atOrPast)); // Pass current priority so the marker takes effect immediately. controller.selectButtonOk(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 38896dba3f1..9ae8d690466 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -76,7 +76,7 @@ public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { this.autoPassUntilEOT = active; } - public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase) { + public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; if (phaseOwner == null || phase == null) { clearMarker(); @@ -85,9 +85,8 @@ public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase) { autoPassUntilMarker = new YieldMarker(phaseOwner, phase); // Activating at-or-past target on the owner's current turn must wait for next turn's // occurrence; otherwise pastTarget would fire and clear the marker on the same turn. - boolean atOrPast = isPriorityAtOrPastMarker(autoPassUntilMarker); - hasLeftMarker = !atOrPast; - activationOnMarker = atOrPast; + hasLeftMarker = !atOrPastAtClick; + activationOnMarker = atOrPastAtClick; } public synchronized void clearMarker() { @@ -96,15 +95,14 @@ public synchronized void clearMarker() { activationOnMarker = false; } - private boolean isPriorityAtOrPastMarker(YieldMarker m) { - if (m == null || owner == null || owner.getGui() == null) return false; - GameView gv = owner.getGui().getGameView(); - if (gv == null) return false; + /** Click-site helper: true when priority is at or past {@code phase} on {@code phaseOwner}'s current turn. */ + public static boolean isPriorityAtOrPastMarker(GameView gv, PlayerView phaseOwner, PhaseType phase) { + if (gv == null || phaseOwner == null || phase == null) return false; PlayerView turnPlayer = gv.getPlayerTurn(); - PhaseType phase = gv.getPhase(); - if (turnPlayer == null || !turnPlayer.equals(m.getPhaseOwner())) return false; - if (phase == null || m.getPhase() == null) return false; - return phase == m.getPhase() || phase.isAfter(m.getPhase()); + PhaseType currentPhase = gv.getPhase(); + if (turnPlayer == null || !turnPlayer.equals(phaseOwner)) return false; + if (currentPhase == null) return false; + return currentPhase == phase || currentPhase.isAfter(phase); } /** @@ -133,7 +131,13 @@ public boolean shouldAutoYield() { if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) return true; autoPassUntilStackEmpty = false; } - checkAndClearMarker(gv); + // Priority-loop fires the marker on the game thread; refresh the chevron here + // because the EDT phase-event listener may already have observed the marker as + // null (race-loss) and skipped its own refresh. + if (checkAndClearMarker(gv) && owner != null && owner.getGui() != null) { + PlayerView local = owner.getLocalPlayerView(); + if (local != null) owner.getGui().refreshYieldUi(local); + } return autoPassUntilMarker != null; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index 5cb5aa6ae69..4a8e6da1293 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -23,7 +23,8 @@ public sealed interface YieldUpdate extends Serializable YieldUpdate.SkipPhase, YieldUpdate.SeedFromClient { - record SetMarker(PlayerView phaseOwner, PhaseType phase) implements YieldUpdate {} + /** {@code atOrPastAtClick}: priority was at-or-past target on owner's turn when the user clicked — computed by the UI so client cache and host PCH initialize identically. */ + record SetMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) implements YieldUpdate {} record ClearMarker(PlayerView player) implements YieldUpdate {} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 26eb6a93973..8fb57b983f1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -215,7 +215,7 @@ public void applyYieldUpdate(final YieldUpdate update) { // Local self-apply for marker/stack-yield user actions that route through // sendYieldUpdate. Other cases dispatch via dedicated setters above. if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase()); + yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); } else if (update instanceof YieldUpdate.StackYield u) { diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 6b64e1ca4c6..2fe62293df4 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3554,7 +3554,7 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p @Override public void applyYieldUpdate(final YieldUpdate update) { if (update instanceof YieldUpdate.SetMarker u) { - yieldController.setMarker(u.phaseOwner(), u.phase()); + yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); } else if (update instanceof YieldUpdate.StackYield u) { From 48166cdaec58f84663310c34968707ae435f4940 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 1 May 2026 12:34:52 +0930 Subject: [PATCH 16/21] Make user-cancel of yields work end-to-end on remote client Per tool4ever review (r3170857314): host-side cancel routing stays at master's InputLockUI.selectButtonCancel -- uniform regardless of which Input is active. That's correct on the host, but it's not sufficient by itself in network play. The remote client caches its own YieldController for chevron rendering, and round-2 deleted the server->client applyYieldUpdate propagation path, so host-side state changes no longer reach the client automatically. And on the host, a cancel can route to a non-LockUI Input (e.g. InputPayMana) which never reaches the autoPassCancel-for-all-players loop in InputLockUI.selectButtonCancel. Four client-side fixes were needed: - Yield prompt persists across opponent-turn waits: updatePromptForAwait and the 1s elapsed-time tick append "Waiting for opponent..." below the yield text instead of replacing it. - Yield-cancel detection by exact prompt match (lastPromptMessage) instead of cancel-button-label heuristic, so InputPayMana's "Cancel" doesn't disarm a marker. - Cancel-button click sends ClearMarker/StackYield(false) via sendYieldUpdate -- self-applies locally (chevron) and ships to host so server-side state clears regardless of which Input is active. - Clearing the marker (cancel, right-click toggle-off, host fast-forward past target) refreshes the prompt locally via a new refreshPromptAfterLocalYieldClear() so stale "Yielding until..." text drops immediately. Adjacent: FDialog only consumes ESC when modal/focused; PCH.applyYieldUpdate triggers updateAutoPassPrompt on yield activation; lblYieldingUntilPhaseFmt apostrophe escape ('s -> ''s) for MessageFormat. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 5 +- .../screens/match/controllers/CPrompt.java | 5 +- .../forge/screens/match/views/VPrompt.java | 20 +-- .../src/main/java/forge/view/FDialog.java | 13 +- .../java/forge/net/HeadlessNetworkClient.java | 2 +- .../forge/net/HeadlessNetworkGuiGame.java | 2 +- .../forge/screens/match/MatchController.java | 5 +- .../src/forge/screens/match/MatchScreen.java | 15 +- forge-gui/res/languages/en-US.properties | 2 +- .../gamemodes/match/AbstractGuiGame.java | 140 ++++++++++++++---- .../gamemodes/match/YieldController.java | 20 --- .../gamemodes/match/input/InputLockUI.java | 15 +- .../net/server/RemoteClientGuiGame.java | 2 +- .../forge/player/PlayerControllerHuman.java | 9 ++ 14 files changed, 160 insertions(+), 95 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index d14cb831924..175c4631be8 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -1036,7 +1036,7 @@ public void popupMenuCanceled(PopupMenuEvent e) { } @Override - public void showPromptMessage(final PlayerView playerView, final String message) { + protected void doShowPromptMessage(final PlayerView playerView, final String message) { cancelWaitingTimer(); cPrompt.setMessage(message); } @@ -1379,6 +1379,9 @@ private void handleYieldMarkerToggle(final PhaseLabel label, final PlayerView ph controller.selectButtonOk(); } refreshYieldUi(local); + if (clickedSameLabel) { + refreshPromptAfterLocalYieldClear(); + } } @Override diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java index 66d60d8b189..af137ee7582 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java @@ -64,7 +64,10 @@ public final VPrompt getView() { private Component lastFocusedButton = null; - private final ActionListener actCancel = evt -> selectButtonCancel(); + private final ActionListener actCancel = evt -> { + getMatchUI().clearLocalYieldsForCancel(); + selectButtonCancel(); + }; private final ActionListener actOK = evt -> selectButtonOk(); private final WindowAdapter focusOKButtonOnDialogClose = new WindowAdapter() { diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java index 65e9a1f11c5..fa495026e6d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/views/VPrompt.java @@ -30,16 +30,13 @@ import javax.swing.SwingConstants; import forge.game.card.CardView; -import forge.game.player.PlayerView; import forge.gui.framework.DragCell; import forge.gui.framework.DragTab; import forge.gui.framework.EDocID; import forge.gui.framework.IVDoc; -import forge.interfaces.IGameController; import forge.localinstance.properties.ForgePreferences; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; -import forge.screens.match.CMatchUI; import forge.screens.match.controllers.CPrompt; import forge.toolbox.FButton; import forge.toolbox.FHtmlViewer; @@ -77,18 +74,11 @@ public void setCardView(final CardView card) { private KeyAdapter buttonKeyAdapter = new KeyAdapter() { @Override public void keyPressed(final KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { - if (btnCancel.isEnabled() && - (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { - btnCancel.doClick(); - return; - } - CMatchUI ui = controller.getMatchUI(); - PlayerView local = ui.getCurrentPlayer(); - IGameController ctrl = local != null ? ui.getGameController(local) : null; - if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { - ui.refreshYieldUi(local); - } + if (e.getKeyCode() == KeyEvent.VK_ESCAPE + && btnCancel.isEnabled() + && (FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) + || !btnCancel.getText().equals(Localizer.getInstance().getMessage("lblEndTurn")))) { + btnCancel.doClick(); } } }; diff --git a/forge-gui-desktop/src/main/java/forge/view/FDialog.java b/forge-gui-desktop/src/main/java/forge/view/FDialog.java index cdf9e665e1a..d727c802178 100644 --- a/forge-gui-desktop/src/main/java/forge/view/FDialog.java +++ b/forge-gui-desktop/src/main/java/forge/view/FDialog.java @@ -130,12 +130,13 @@ public void componentResized(final ComponentEvent e) { //must update shape whene //Make Escape key close dialog if allowed @Override public boolean dispatchKeyEvent(final KeyEvent e) { - if (e.getID() == KeyEvent.KEY_PRESSED) { - if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { - final WindowEvent wev = new WindowEvent(this, WindowEvent.WINDOW_CLOSING); - Toolkit.getDefaultToolkit().getSystemEventQueue().postEvent(wev); - return true; - } + if (e.getID() == KeyEvent.KEY_PRESSED && e.getKeyCode() == KeyEvent.VK_ESCAPE) { + // Non-modal floats (e.g. the chat panel) shouldn't swallow ESC unless the user + // has actually focused them — otherwise pressing ESC anywhere in the app closes + // any open non-modal instead of running the focused window's own ESC handler. + if (!isModal() && !isFocused()) return false; + Toolkit.getDefaultToolkit().getSystemEventQueue().postEvent(new WindowEvent(this, WindowEvent.WINDOW_CLOSING)); + return true; } return false; } diff --git a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java index 491617ab770..12d221f7e64 100644 --- a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java +++ b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java @@ -394,7 +394,7 @@ public void setGameController(forge.game.player.PlayerView player, IGameControll } @Override - public void showPromptMessage(forge.game.player.PlayerView playerView, String message) { + protected void doShowPromptMessage(forge.game.player.PlayerView playerView, String message) { netLog.info("Prompt: {}", message); // Detect player selection prompts (like "who goes first") diff --git a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java index 7539d2962c3..6f0083f23ce 100644 --- a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java +++ b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java @@ -74,7 +74,7 @@ public void setGameView(forge.game.GameView gameView) { @Override public void showCombat() { } @Override public void finishGame() { } - @Override public void showPromptMessage(PlayerView playerView, String message) { } + @Override protected void doShowPromptMessage(PlayerView playerView, String message) { } @Override public void showCardPromptMessage(PlayerView playerView, String message, CardView card) { } @Override public void updateButtons(PlayerView owner, String label1, String label2, boolean enable1, boolean enable2, boolean focus1) { } @Override public void flashIncorrectAction() { } diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 028118b4fc5..03fef48ca6f 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -213,7 +213,7 @@ public void buildTouchListeners(final float screenX, final float screenY, final } @Override - public void showPromptMessage(final PlayerView player, final String message) { + protected void doShowPromptMessage(final PlayerView player, final String message) { cancelWaitingTimer(); view.getPrompt(player).setMessage(message); } @@ -608,6 +608,9 @@ private void handleYieldMarkerToggle(final VPhaseIndicator.PhaseLabel label, fin controller.selectButtonOk(); } refreshYieldUi(local); + if (clickedSameLabel) { + refreshPromptAfterLocalYieldClear(); + } } public static void writeMatchPreferences() { diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 3b2e51381f5..4982d852539 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -123,7 +123,7 @@ public MatchScreen(List playerPanels0) { bottomPlayerPrompt = add(new VPrompt("", "", e -> getGameController().selectButtonOk(), - e -> getGameController().selectButtonCancel())); + e -> { MatchController.instance.clearLocalYieldsForCancel(); getGameController().selectButtonCancel(); })); if (humanCount < 2 || MatchController.instance.hotSeatMode() || GuiBase.isNetPlay(MatchController.instance)) topPlayerPrompt = null; @@ -131,7 +131,7 @@ public MatchScreen(List playerPanels0) { //show top prompt if multiple human players and not playing in Hot Seat mode and not in network play topPlayerPrompt = add(new VPrompt("", "", e -> getGameController().selectButtonOk(), - e -> getGameController().selectButtonCancel())); + e -> { MatchController.instance.clearLocalYieldsForCancel(); getGameController().selectButtonCancel(); })); topPlayerPrompt.setRotate180(true); topPlayerPanel.setRotate180(true); getHeader().setRotate90(true); @@ -631,16 +631,7 @@ public boolean keyDown(int keyCode) { case Keys.ESCAPE: { boolean cancelEligible = FModel.getPreferences().getPrefBoolean(FPref.UI_ALLOW_ESC_TO_END_TURN) || Forge.hasGamepad() || !getActivePrompt().getBtnCancel().getText().equals(Forge.getLocalizer().getMessage("lblEndTurn")); - if (cancelEligible) { - return getActivePrompt().getBtnCancel().trigger(); - } - PlayerView local = MatchController.instance.getCurrentPlayer(); - IGameController ctrl = local != null ? MatchController.instance.getGameController(local) : null; - if (ctrl != null && ctrl.getYieldController().cancelGenericYields(local, ctrl)) { - MatchController.instance.refreshYieldUi(local); - return true; - } - return false; + return cancelEligible && getActivePrompt().getBtnCancel().trigger(); } case Keys.BACK: return true; //suppress Back button so it's not bumped when trying to press OK or Cancel buttons diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index bc3cf2a3d4d..8b8ca9e4b89 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -1591,7 +1591,7 @@ lblCloseGameSpectator=This will close this game and you will not be able to resu lblCloseGame=Close Game? lblWaitingForOpponent=Waiting for opponent... lblYieldingUntilEndOfTurn=Yielding until end of turn.\nYou may cancel this yield to take an action. -lblYieldingUntilPhaseFmt=Yielding until %s.\nYou may cancel this yield to take an action. +lblYieldingUntilPhaseFmt=Yielding until {0}''s {1}.\nYou may cancel this yield to take an action. lblYieldingUntilStackClears=Yielding until stack clears.\nYou may cancel this yield to take an action. lblYieldToEntireStack=Yield to entire stack lblStopWatching=Stop Watching diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index 295a9a34389..aed0e93b400 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -454,8 +454,62 @@ private void checkAwaitNextInputTimer() { } protected final void updatePromptForAwait(final PlayerView playerView) { - showPromptMessage(playerView, Localizer.getInstance().getMessage("lblWaitingForOpponent")); - updateButtons(playerView, false, false, false); + // Append "Waiting for opponent..." below the yield prompt so the user keeps the + // cancel-yield UI during opponent turns instead of losing it to the await prompt. + String waiting = Localizer.getInstance().getMessage("lblWaitingForOpponent"); + String yieldMsg = currentYieldMessage(); + if (yieldMsg != null) { + cancelAwaitNextInput(); + showPromptMessage(playerView, yieldMsg + "\n\n" + waiting); + updateButtons(playerView, false, true, false); + } else { + showPromptMessage(playerView, waiting); + updateButtons(playerView, false, false, false); + } + } + + private String currentYieldMessage() { + YieldController yielding = null; + for (IGameController c : gameControllers.values()) { + YieldController yc = c.getYieldController(); + if (yc != null && yc.shouldAutoYield()) { + yielding = yc; + break; + } + } + if (yielding == null) return null; + Localizer loc = Localizer.getInstance(); + if (yielding.getAutoPassUntilMarker() != null) { + YieldMarker m = yielding.getAutoPassUntilMarker(); + return loc.getMessage("lblYieldingUntilPhaseFmt", m.getPhaseOwner().getName(), m.getPhase().nameForUi); + } + if (yielding.autoPassUntilEOT()) { + return loc.getMessage("lblYieldingUntilStackClears"); + } + return loc.getMessage("lblYieldingUntilEndOfTurn"); + } + + private String lastPromptMessage; + + @Override + public final void showPromptMessage(PlayerView playerView, String message) { + lastPromptMessage = message; + doShowPromptMessage(playerView, message); + } + + /** Subclass-specific prompt rendering. The {@code final} wrapper above tracks state so + * {@link #isYieldPromptShowing()} can answer "is the user looking at the auto-pass UI?" + * without relying on cancel-button-label heuristics. */ + protected abstract void doShowPromptMessage(PlayerView playerView, String message); + + /** True when the prompt currently rendered is the auto-pass yield prompt — either alone + * (from {@link #updateAutoPassPrompt}) or with the await line appended (from + * {@link #updatePromptForAwait}). Used by client-side cancel paths to distinguish a + * yield-cancel from an unrelated input cancel that happens to share the cancel button. */ + public boolean isYieldPromptShowing() { + String yieldMsg = currentYieldMessage(); + if (yieldMsg == null || lastPromptMessage == null) return false; + return lastPromptMessage.equals(yieldMsg) || lastPromptMessage.startsWith(yieldMsg + "\n"); } @Override @@ -492,7 +546,15 @@ private void updateWaitingDisplay(final PlayerView forPlayer, final String waiti } else { timeStr = String.format("%d:%02d", elapsedSec / 60, elapsedSec % 60); } - showPromptMessageNoCancel(forPlayer, Localizer.getInstance().getMessage("lblWaitingForPlayer", waitingForPlayerName) + " (" + timeStr + ")"); + String waiting = Localizer.getInstance().getMessage("lblWaitingForPlayer", waitingForPlayerName) + " (" + timeStr + ")"; + // Preserve the yield prompt above the timer line — otherwise the 1s timer overwrites + // the yield-aware text from updatePromptForAwait. Update lastPromptMessage directly so + // isYieldPromptShowing() stays accurate; route through showPromptMessageNoCancel so we + // don't cancel the timer that's calling us. + String yieldMsg = currentYieldMessage(); + String message = yieldMsg != null ? yieldMsg + "\n\n" + waiting : waiting; + lastPromptMessage = message; + showPromptMessageNoCancel(forPlayer, message); } protected void cancelWaitingTimer() { @@ -550,30 +612,41 @@ public final void cancelAwaitNextInput() { @Override public final void updateAutoPassPrompt() { - YieldController yielding = null; - for (IGameController c : gameControllers.values()) { - YieldController yc = c.getYieldController(); - if (yc != null && yc.shouldAutoYield()) { - yielding = yc; - break; - } - } - if (yielding == null) { - return; - } - Localizer loc = Localizer.getInstance(); - final String message; - if (yielding.getAutoPassUntilMarker() != null) { - message = loc.getMessage("lblYieldingUntilPhaseFmt", yielding.getAutoPassUntilMarker().getPhase().nameForUi); - } else if (yielding.autoPassUntilEOT()) { - message = loc.getMessage("lblYieldingUntilStackClears"); - } else { - message = loc.getMessage("lblYieldingUntilEndOfTurn"); - } + String message = currentYieldMessage(); + if (message == null) return; cancelAwaitNextInput(); showPromptMessage(getCurrentPlayer(), message); updateButtons(getCurrentPlayer(), false, true, false); } + + /** Called by client-side cancel paths after they've locally cleared yield state and + * shipped the wire update. Drops the stale yield prompt without waiting for the host's + * response so the user sees the correct state immediately. */ + public final void refreshPromptAfterLocalYieldClear() { + updatePromptForAwait(getCurrentPlayer()); + } + + /** Cancel-button click handler for the auto-pass UI: clear marker and stack-yield via + * sendYieldUpdate so the host's authoritative state clears regardless of which Input is + * active there (e.g. InputPayMana doesn't route cancel through InputLockUI). Self-applies + * on the client so the chevron and prompt update without waiting for the host response. */ + public final void clearLocalYieldsForCancel() { + if (!isYieldPromptShowing()) return; + PlayerView local = getCurrentPlayer(); + if (local == null) return; + IGameController ctrl = getGameController(local); + if (ctrl == null) return; + YieldController yc = ctrl.getYieldController(); + if (yc == null) return; + if (yc.getAutoPassUntilMarker() != null) { + ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); + } + if (yc.autoPassUntilEOT()) { + ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); + } + refreshYieldUi(local); + refreshPromptAfterLocalYieldClear(); + } // End auto-yield/input code /** @@ -804,25 +877,28 @@ public void applyDelta(DeltaPacket packet) { } /** - * Phase-change derivation: refresh chevron + auto-pass prompt when a controller's marker - * has cleared since the last check. The clearing itself may have happened either here - * (this listener calling {@link YieldController#checkAndClearMarker}) or in the host's - * {@link YieldController#shouldAutoYield} priority loop — we just detect the transition. + * Phase-change derivation: clear any marker whose target the current phase has reached and + * refresh the chevron + auto-pass prompt. The host's {@link YieldController#shouldAutoYield} + * priority loop refreshes its own UI for the race-loss case where it clears the marker + * before this listener runs. */ public final void checkMarkerAutoClear() { GameView gv = getGameView(); if (gv == null) return; + boolean wasYieldPromptShowing = isYieldPromptShowing(); boolean any = false; for (Map.Entry e : gameControllers.entrySet()) { YieldController yc = e.getValue().getYieldController(); - if (yc == null) continue; - boolean hadMarker = yc.getAutoPassUntilMarker() != null; - yc.checkAndClearMarker(gv); - if (hadMarker && yc.getAutoPassUntilMarker() == null) { + if (yc != null && yc.checkAndClearMarker(gv)) { refreshYieldUi(e.getKey()); any = true; } } - if (any) updateAutoPassPrompt(); + if (!any) return; + if (currentYieldMessage() != null) { + updateAutoPassPrompt(); + } else if (wasYieldPromptShowing) { + updatePromptForAwait(getCurrentPlayer()); + } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 9ae8d690466..136760053a1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -3,7 +3,6 @@ import forge.game.GameView; import forge.game.phase.PhaseType; import forge.game.player.PlayerView; -import forge.interfaces.IGameController; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; @@ -105,25 +104,6 @@ public static boolean isPriorityAtOrPastMarker(GameView gv, PlayerView phaseOwne return currentPhase == phase || currentPhase.isAfter(phase); } - /** - * User-initiated ESC: clear marker, legacy autopass, and stack-yield (the three "active" - * yield forms). Persistent per-card auto-yields are unaffected. Marker and stack-yield go - * over the wire via the controller; legacy autopass is local to host PCH. - * - * @return true if any of the three were active and got cleared (caller should refresh UI - * and suppress the ESC fallthrough); false if nothing was active. - */ - public boolean cancelGenericYields(PlayerView local, IGameController controller) { - boolean hadMarker = autoPassUntilMarker != null; - boolean hadLegacy = autoPassUntilEOT; - boolean hadStackYield = autoPassUntilStackEmpty; - if (!hadMarker && !hadLegacy && !hadStackYield) return false; - if (hadMarker) controller.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - if (hadLegacy && controller instanceof PlayerControllerHuman pch) pch.autoPassCancel(); - if (hadStackYield) controller.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); - return true; - } - public boolean shouldAutoYield() { if (autoPassUntilEOT) return true; GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java index be1a4600bcb..73d542e6caf 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java @@ -58,8 +58,15 @@ public void run() { private final Runnable showMessageFromEdt = new Runnable() { @Override public void run() { - controller.getGui().updateButtons(InputLockUI.this.getOwner(), "", "", false, false, false); - showMessage(Localizer.getInstance().getMessage("lblWaitingforActions")); + // While auto-yielding, show the yield prompt with cancel enabled so the user can + // disarm via the cancel button (which routes back to selectButtonCancel below). + // Otherwise show master's "Waiting for actions..." prompt with both buttons disabled. + if (controller.getYieldController().shouldAutoYield()) { + controller.getGui().updateAutoPassPrompt(); + } else { + controller.getGui().updateButtons(InputLockUI.this.getOwner(), "", "", false, false, false); + showMessage(Localizer.getInstance().getMessage("lblWaitingforActions")); + } } }; @@ -87,7 +94,9 @@ public void selectButtonOK() { } @Override public void selectButtonCancel() { - //cancel auto pass for all players + // Master pattern: cancel auto-pass for all players. Marker and stack-yield are + // cleared client-side via the cancel-button click path's sendYieldUpdate calls + // (see VPrompt.clearLocalYieldsForCancel), so they don't need to be handled here. for (final Player player : controller.getGame().getPlayers()) { player.getController().autoPassCancel(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index f9193b422e2..e296087a13c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -270,7 +270,7 @@ public void showCombat() { } @Override - public void showPromptMessage(final PlayerView playerView, final String message) { + protected void doShowPromptMessage(final PlayerView playerView, final String message) { updateGameView(); send(ProtocolMethod.showPromptMessage, playerView, message); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 2fe62293df4..99c915c2599 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3553,12 +3553,15 @@ public boolean isUiSetToSkipPhase(final PlayerView turnPlayer, final PhaseType p @Override public void applyYieldUpdate(final YieldUpdate update) { + boolean activatedYield = false; if (update instanceof YieldUpdate.SetMarker u) { yieldController.setMarker(u.phaseOwner(), u.phase(), u.atOrPastAtClick()); + activatedYield = true; } else if (update instanceof YieldUpdate.ClearMarker) { yieldController.clearMarker(); } else if (update instanceof YieldUpdate.StackYield u) { yieldController.setAutoPassUntilStackEmpty(u.active()); + activatedYield = u.active(); } else if (update instanceof YieldUpdate.TriggerDecision u) { yieldController.setTriggerDecision(u.trigId(), u.decision()); } else if (update instanceof YieldUpdate.CardAutoYield u) { @@ -3568,5 +3571,11 @@ public void applyYieldUpdate(final YieldUpdate update) { } else if (update instanceof YieldUpdate.SeedFromClient u) { yieldController.applyClientSeed(u.snapshot()); } + if (activatedYield && getGui() != null) { + // Switch the cancel button + prompt to "Yielding until X" so the user can disarm. + // Otherwise the previous InputPassPriority "End Turn" label would persist and ESC + // would skip the click on the client. + getGui().updateAutoPassPrompt(); + } } } From 7583f4a11abe6c4de93739585ee585406ef7cfa8 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 2 May 2026 08:02:08 +0930 Subject: [PATCH 17/21] Consolidate per-phase tooltip strings into one format key tool4ever review (r3172286642): the 12 htmlPhase*Tooltip strings all followed the same "Phase:
    Click to toggle." template. Replace them with a single htmlPhaseTooltipFmt that takes PhaseType.nameForUi as the format argument. PhaseIndicator.addPhaseLabel loses its tooltipKey parameter and builds the tooltip inline. The shared text also documents the new right-click-to-fast-forward behavior introduced earlier on this branch. Removed 12 keys from each of the 9 .properties files. New key only added to en-US -- Localizer falls back to English when missing in the active locale, so translators can fill in localized versions later without blocking this consolidation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge/toolbox/special/PhaseIndicator.java | 28 +++++++++---------- forge-gui/res/languages/de-DE.properties | 14 ---------- forge-gui/res/languages/en-US.properties | 13 +-------- forge-gui/res/languages/es-ES.properties | 14 ---------- forge-gui/res/languages/fr-FR.properties | 14 ---------- forge-gui/res/languages/it-IT.properties | 14 ---------- forge-gui/res/languages/ja-JP.properties | 14 ---------- forge-gui/res/languages/ko-KR.properties | 14 ---------- forge-gui/res/languages/pt-BR.properties | 14 ---------- forge-gui/res/languages/zh-CN.properties | 14 ---------- 10 files changed, 15 insertions(+), 138 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java index 37259b3e5e8..0ae1a26cb5a 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/special/PhaseIndicator.java @@ -19,23 +19,23 @@ public class PhaseIndicator extends JPanel { public PhaseIndicator() { this.setOpaque(false); this.setLayout(new MigLayout("insets 0 0 1% 0, gap 0, wrap")); - addPhaseLabel("UP", PhaseType.UPKEEP, "htmlPhaseUpkeepTooltip"); - addPhaseLabel("DR", PhaseType.DRAW, "htmlPhaseDrawTooltip"); - addPhaseLabel("M1", PhaseType.MAIN1, "htmlPhaseMain1Tooltip"); - addPhaseLabel("BC", PhaseType.COMBAT_BEGIN, "htmlPhaseBeginCombatTooltip"); - addPhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS, "htmlPhaseDeclareAttackersTooltip"); - addPhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS, "htmlPhaseDeclareBlockersTooltip"); - addPhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE, "htmlPhaseFirstStrikeDamageTooltip"); - addPhaseLabel("CD", PhaseType.COMBAT_DAMAGE, "htmlPhaseCombatDamageTooltip"); - addPhaseLabel("EC", PhaseType.COMBAT_END, "htmlPhaseEndCombatTooltip"); - addPhaseLabel("M2", PhaseType.MAIN2, "htmlPhaseMain2Tooltip"); - addPhaseLabel("ET", PhaseType.END_OF_TURN, "htmlPhaseEndTurnTooltip"); - addPhaseLabel("CL", PhaseType.CLEANUP, "htmlPhaseCleanupTooltip"); + addPhaseLabel("UP", PhaseType.UPKEEP); + addPhaseLabel("DR", PhaseType.DRAW); + addPhaseLabel("M1", PhaseType.MAIN1); + addPhaseLabel("BC", PhaseType.COMBAT_BEGIN); + addPhaseLabel("DA", PhaseType.COMBAT_DECLARE_ATTACKERS); + addPhaseLabel("DB", PhaseType.COMBAT_DECLARE_BLOCKERS); + addPhaseLabel("FS", PhaseType.COMBAT_FIRST_STRIKE_DAMAGE); + addPhaseLabel("CD", PhaseType.COMBAT_DAMAGE); + addPhaseLabel("EC", PhaseType.COMBAT_END); + addPhaseLabel("M2", PhaseType.MAIN2); + addPhaseLabel("ET", PhaseType.END_OF_TURN); + addPhaseLabel("CL", PhaseType.CLEANUP); } - private void addPhaseLabel(String caption, PhaseType phaseType, String tooltipKey) { + private void addPhaseLabel(String caption, PhaseType phaseType) { PhaseLabel lbl = new PhaseLabel(caption, phaseType); - lbl.setToolTipText(Localizer.getInstance().getMessage(tooltipKey)); + lbl.setToolTipText(Localizer.getInstance().getMessage("htmlPhaseTooltipFmt", phaseType.nameForUi)); phaseLabels.put(phaseType, lbl); add(lbl, CONSTRAINTS); } diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index e94d2ea210b..d9449455b1e 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -2941,20 +2941,6 @@ lblAllTokens=Alle Spielsteine #StartRenderer.java lblClickToAddTargetToFavorites=Klicke um {0} zu deinen Favoriten hinzuzufügen lblClickToRemoveTargetToFavorites=Klicke um {0} aus deinen Favoriten zu entfernen -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Phase: Versorgung
    Klicke zum Umschalten. -htmlPhaseDrawTooltip=Phase: Karte ziehen
    Klicke zum Umschalten. -htmlPhaseMain1Tooltip=Phase: Hauptphase 1
    Klicke zum Umschalten. -htmlPhaseBeginCombatTooltip=Phase: Vor dem Kampf
    Klicke zum Umschalten. -htmlPhaseDeclareAttackersTooltip=Phase: Angeifer deklarieren
    Klicke zum Umschalten. -htmlPhaseDeclareBlockersTooltip=Phase: Blocker deklarieren
    Klicke zum Umschalten. -htmlPhaseFirstStrikeDamageTooltip=Phase: Erstschlagschaden
    Klicke zum Umschalten. -htmlPhaseCombatDamageTooltip=Phase: Kampfschaden
    Klicke zum Umschalten. -htmlPhaseEndCombatTooltip=Phase: Ende des Kampfes
    Klicke zum Umschalten. -htmlPhaseMain2Tooltip=Phase: Hauptphase 2
    Klicke zum Umschalten. -htmlPhaseEndTurnTooltip=Phase: Ende des Zuges
    Klicke zum Umschalten. -htmlPhaseCleanupTooltip=Phase: Aufräumen
    Klicke zum Umschalten. #GuiChoose.java lblSideboardForPlayer=Sideboard für {0} lblOtherInteger=Andere... diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 8b8ca9e4b89..f4099760099 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -3092,18 +3092,7 @@ lblClickToAddTargetToFavorites=Click to add {0} to your favorites lblClickToRemoveTargetToFavorites=Click to remove {0} from your favorites #PhaseIndicator.java #translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Phase: Upkeep
    Click to toggle. -htmlPhaseDrawTooltip=Phase: Draw
    Click to toggle. -htmlPhaseMain1Tooltip=Phase: Main 1
    Click to toggle. -htmlPhaseBeginCombatTooltip=Phase: Begin Combat
    Click to toggle. -htmlPhaseDeclareAttackersTooltip=Phase: Declare Attackers
    Click to toggle. -htmlPhaseDeclareBlockersTooltip=Phase: Declare Blockers
    Click to toggle. -htmlPhaseFirstStrikeDamageTooltip=Phase: First Strike Damage
    Click to toggle. -htmlPhaseCombatDamageTooltip=Phase: Combat Damage
    Click to toggle. -htmlPhaseEndCombatTooltip=Phase: End Combat
    Click to toggle. -htmlPhaseMain2Tooltip=Phase: Main 2
    Click to toggle. -htmlPhaseEndTurnTooltip=Phase: End Turn
    Click to toggle. -htmlPhaseCleanupTooltip=Phase: Cleanup
    Click to toggle. +htmlPhaseTooltipFmt=Phase: {0}
    Click to toggle. Right-click to fast-forward. #GuiChoose.java lblSideboardForPlayer=Sideboard for {0} lblOtherInteger=Other... diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 1439903e722..7dc0330b6cb 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -2932,20 +2932,6 @@ lblAllTokens=Todas las fichas #StartRenderer.java lblClickToAddTargetToFavorites=Clic para añadir {0} a tus favoritos lblClickToRemoveTargetToFavorites=Clic para eliminar {0} de tus favoritos -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Fase: Mantenimiento
    Clic para alternar. -htmlPhaseDrawTooltip=Fase: Robar
    Clic para alternar. -htmlPhaseMain1Tooltip=Fase: Principal 1
    Clic para alternar. -htmlPhaseBeginCombatTooltip=Fase: Inicio del Combate
    Clic para alternar. -htmlPhaseDeclareAttackersTooltip=Fase: Declarar Atacantes
    Clic para alternar. -htmlPhaseDeclareBlockersTooltip=Fase: Declarar Bloqueadoras
    Clic para alternar. -htmlPhaseFirstStrikeDamageTooltip=Fase: Daño Dañar Primero
    Clic para alternar. -htmlPhaseCombatDamageTooltip=Fase: Daño de Combate
    Clic para alternar. -htmlPhaseEndCombatTooltip=Fase: Final del Combate
    Clic para alternar. -htmlPhaseMain2Tooltip=Fase: Principal 2
    Clic para alternar. -htmlPhaseEndTurnTooltip=Fase: Fin del Turno
    Clic para alternar. -htmlPhaseCleanupTooltip=Fase: Limpieza
    Clic para alternar. #GuiChoose.java lblSideboardForPlayer=Banquillo para {0} lblOtherInteger=Otro... diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index caebb0cabd4..3987edf1512 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -2926,20 +2926,6 @@ lblAllTokens=Tous les jetons #StartRenderer.java lblClickToAddTargetToFavorites=Cliquez pour ajouter {0} à vos favoris lblClickToRemoveTargetToFavorites=Cliquez pour supprimer {0} de vos favoris -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Phase : Entretien
    Cliquez pour basculer. -htmlPhaseDrawTooltip=Phase : Dessiner
    Cliquez pour basculer. -htmlPhaseMain1Tooltip=Phase : Principal 1
    Cliquez pour basculer. -htmlPhaseBeginCombatTooltip=Phase : commencer le combat
    Cliquez pour basculer. -htmlPhaseDeclareAttackersTooltip=Phase : Déclarer les attaquants
    Cliquez pour basculer. -htmlPhaseDeclareBlockersTooltip=Phase : Déclarer les bloqueurs
    Cliquez pour basculer. -htmlPhaseFirstStrikeDamageTooltip=Phase : Dégâts de la première frappe
    Cliquez pour basculer. -htmlPhaseCombatDamageTooltip=Phase : Dégâts de combat
    Cliquez pour basculer. -htmlPhaseEndCombatTooltip=Phase : Fin du combat
    Cliquez pour basculer. -htmlPhaseMain2Tooltip=Phase : Principal 2
    Cliquez pour basculer. -htmlPhaseEndTurnTooltip=Phase : fin de virage
    Cliquez pour basculer. -htmlPhaseCleanupTooltip=Phase : Nettoyage
    Cliquez pour basculer. #GuiChoose.java lblSideboardForPlayer=Sideboard pour {0} lblOtherInteger=Autre... diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index de43c1786e4..2ac249f643d 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -2924,20 +2924,6 @@ lblAllTokens=Tutti i segnalini #StartRenderer.java lblClickToAddTargetToFavorites=Clicca per aggiungere {0} ai preferiti lblClickToRemoveTargetToFavorites=Clicca per rimuovere {0} ai preferiti -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Fase: Mantenimento
    Clic per (dis)attivare. -htmlPhaseDrawTooltip=Fase: Acquisizione
    Clic per (dis)attivare. -htmlPhaseMain1Tooltip=Fase: Principale 1
    Clic per (dis)attivare. -htmlPhaseBeginCombatTooltip=Fase: Inizio combattimento
    Clic per (dis)attivare. -htmlPhaseDeclareAttackersTooltip=Fase: Dichiara attaccanti
    Clic per (dis)attivare. -htmlPhaseDeclareBlockersTooltip=Fase: Dichiara bloccanti
    Clic per (dis)attivare. -htmlPhaseFirstStrikeDamageTooltip=Fase: Danno attacco improvviso
    Clic per (dis)attivare. -htmlPhaseCombatDamageTooltip=Fase: Danno da combattimento
    Clic per (dis)attivare. -htmlPhaseEndCombatTooltip=Fase: Fine combattimento
    Clic per (dis)attivare. -htmlPhaseMain2Tooltip=Fase: Principale 2
    Clic per (dis)attivare. -htmlPhaseEndTurnTooltip=Fase: Finale
    Clic per (dis)attivare. -htmlPhaseCleanupTooltip=Fase: Cancellazione
    Clic per (dis)attivare. #GuiChoose.java lblSideboardForPlayer=Sideboard per {0} lblOtherInteger=Altro... diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index b586b42e17e..b35437030f2 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -2920,20 +2920,6 @@ lblAllTokens=全てのトークン #StartRenderer.java lblClickToAddTargetToFavorites={0}をクリックしてお気に入りに登録します lblClickToRemoveTargetToFavorites={0}をクリックしてお気に入りから外します -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=アップキープ・ステップ
    クリックで切り替え。 -htmlPhaseDrawTooltip=ドロー・ステップ
    クリックで切り替え。 -htmlPhaseMain1Tooltip=戦闘前メイン・フェイズ
    クリックで切り替え。 -htmlPhaseBeginCombatTooltip=戦闘開始ステップ
    クリックで切り替え。 -htmlPhaseDeclareAttackersTooltip=攻撃クリーチャー指定ステップ
    クリックで切り替え。 -htmlPhaseDeclareBlockersTooltip=ブロック・クリーチャー指定ステップ
    クリックで切り替え。 -htmlPhaseFirstStrikeDamageTooltip=先制戦闘ダメージ・ステップ
    クリックで切り替え。 -htmlPhaseCombatDamageTooltip=通常戦闘ダメージ・ステップ
    クリックで切り替え。 -htmlPhaseEndCombatTooltip=戦闘終了ステップ
    クリックで切り替え。 -htmlPhaseMain2Tooltip=戦闘後メイン・フェイズ
    クリックで切り替え。 -htmlPhaseEndTurnTooltip=終了ステップ
    クリックで切り替え。 -htmlPhaseCleanupTooltip=クリンナップ・ステップ
    クリックで切り替え。 #GuiChoose.java lblSideboardForPlayer={0}のサイドボード lblOtherInteger=他... diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 485f2b78bd1..edbb60e54fc 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -3017,20 +3017,6 @@ lblAllTokens=모든 토큰 #StartRenderer.java lblClickToAddTargetToFavorites={0}를 클릭하여 즐겨찾기에 추가합니다 lblClickToRemoveTargetToFavorites={0}를 클릭하여 즐겨찾기에서 제거합니다 -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=업케이프 단계
    클릭으로 전환 -htmlPhaseDrawTooltip=드로우 단계
    클릭으로 전환 -htmlPhaseMain1Tooltip=전투 전 메인 페이즈
    클릭으로 전환 -htmlPhaseBeginCombatTooltip=전투 시작 단계
    클릭으로 전환 -htmlPhaseDeclareAttackersTooltip=공격 생물 지정 단계
    클릭으로 전환 -htmlPhaseDeclareBlockersTooltip=블록 생물 지정 단계
    클릭으로 전환 -htmlPhaseFirstStrikeDamageTooltip=선제 전투 피해 단계
    클릭으로 전환 -htmlPhaseCombatDamageTooltip=일반 전투 피해 단계
    클릭으로 전환 -htmlPhaseEndCombatTooltip=전투 종료 단계
    클릭으로 전환 -htmlPhaseMain2Tooltip=전투 후 메인 페이즈
    클릭으로 전환 -htmlPhaseEndTurnTooltip=종료 단계
    클릭으로 전환 -htmlPhaseCleanupTooltip=정리 단계
    클릭으로 전환 #GuiChoose.java lblSideboardForPlayer={0}의 사이드보드 lblOtherInteger=기타... diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 714cbc2b491..349564f2c76 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -2986,20 +2986,6 @@ lblAllTokens=Todas as Fichas #StartRenderer.java lblClickToAddTargetToFavorites=Clique para adicionar {0} a seus favoritos lblClickToRemoveTargetToFavorites=Clique para remover {0} de seus favoritos -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=Etapa\: Manutenção
    Clique p/ alternar. -htmlPhaseDrawTooltip=Etapa\: Compra
    Clique p/ alternar. -htmlPhaseMain1Tooltip=Etapa\: 1ª Principal
    Clique p/ alternar. -htmlPhaseBeginCombatTooltip=Etapa\: Início de Combate
    Clique p/ alternar. -htmlPhaseDeclareAttackersTooltip=Etapa\: Declaração de Atacantes
    Clique p/ alternar. -htmlPhaseDeclareBlockersTooltip=Etapa\: Declaração de Bloqueadores
    Clique p/ alternar. -htmlPhaseFirstStrikeDamageTooltip=Etapa\: Dano de Iniciativa
    Clique p/ alternar. -htmlPhaseCombatDamageTooltip=Etapa\: Dano de Combate
    Clique p/ alternar. -htmlPhaseEndCombatTooltip=Etapa\: Fim de Combate
    Clique p/ alternar. -htmlPhaseMain2Tooltip=Etapa\: 2ª Principal
    Clique p/ alternar. -htmlPhaseEndTurnTooltip=Etapa\: Fim do Turno
    Clique p/ alternar. -htmlPhaseCleanupTooltip=Etapa\: Limpeza
    Clique p/ alternar. #GuiChoose.java lblSideboardForPlayer=Sideboard para {0} lblOtherInteger=Outro... diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 42b0a123335..dae1fed1196 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -2910,20 +2910,6 @@ lblAllTokens=所有衍生物 #StartRenderer.java lblClickToAddTargetToFavorites=点击以将{0}添加到你的收藏夹 lblClickToRemoveTargetToFavorites=点击以将{0}从你的收藏夹移除 -#PhaseIndicator.java -#translate html*** please keep HTML Tags -htmlPhaseUpkeepTooltip=阶段:维持
    单击切换是否自动让过。 -htmlPhaseDrawTooltip=阶段:抓牌
    单击切换是否自动让过。 -htmlPhaseMain1Tooltip=阶段:主1
    单击切换是否自动让过。 -htmlPhaseBeginCombatTooltip=阶段:战斗开始
    单击切换是否自动让过。 -htmlPhaseDeclareAttackersTooltip=阶段:宣告攻击者
    单击切换是否自动让过。 -htmlPhaseDeclareBlockersTooltip=阶段:宣告阻挡者
    单击切换是否自动让过。 -htmlPhaseFirstStrikeDamageTooltip=阶段:先攻伤害
    单击切换是否自动让过。 -htmlPhaseCombatDamageTooltip=阶段:战斗伤害
    单击切换是否自动让过。 -htmlPhaseEndCombatTooltip=阶段:战斗结束
    单击切换是否自动让过。 -htmlPhaseMain2Tooltip=阶段:主2
    单击切换是否自动让过。 -htmlPhaseEndTurnTooltip=阶段:结束回合
    单击切换是否自动让过。 -htmlPhaseCleanupTooltip=阶段:清除
    单击切换是否自动让过。 #GuiChoose.java lblSideboardForPlayer=为{0}换备 lblOtherInteger=其他数 From c5ac78785879717488517fbad63e50bfcf3ebdd8 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 2 May 2026 08:15:59 +0930 Subject: [PATCH 18/21] fix CI failure Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/forge/screens/match/CMatchUI.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 175c4631be8..68cf24da2ac 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -204,6 +204,10 @@ private void registerDocs() { } } + private static boolean isPreferenceEnabled(final ForgePreferences.FPref preferenceName) { + return FModel.getPreferences().getPrefBoolean(preferenceName); + } + FScreen getScreen() { return this.screen; } From 6bede5770eb28f35a5b53ce885caa46d9b5630bf Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 2 May 2026 21:13:51 +0930 Subject: [PATCH 19/21] =?UTF-8?q?Refactor=20yield=20cancellation=20and=20s?= =?UTF-8?q?erver=E2=86=92client=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancel becomes host-driven: clears all three yield modes per-PCH via the host's InputLockUI; server→client applyYieldUpdate wire restored for chevron sync, with forward-compat for YieldRework's interrupt- driven cancel records (mass-destruction, opponent-targeting). Per-PCH instead of master's all-players iteration -- in netplay that would let a remote client's cancel disarm the host's own auto-pass. - Restore ProtocolMethod.applyYieldUpdate (Mode.SERVER) on the existing YieldUpdate sealed envelope; MVP ships ClearMarker / StackYield. RemoteClientGuiGame override sends over wire; AbstractGuiGame default routes to local IGameController - InputLockUI.selectButtonCancel: per-PCH clear of all three modes; autoPassCancel() runs first to keep its mayAutoPass gate satisfied - Drop client-side machinery: lastPromptMessage, doShowPromptMessage abstract rename, isYieldPromptShowing, clearLocalYieldsForCancel, refreshPromptAfterLocalYieldClear, EDT phase listener checkMarkerAutoClear, actCancel wrappers on desktop CPrompt + mobile MatchScreen - YieldController.checkAndClearMarker now private and unsynchronized (game-thread only via shouldAutoYield); setMarker/clearMarker keep synchronized - updateAutoPassPrompt restarts the elapsed-time timer so "Yielding until X / Waiting for {opponent} (Xs)" stays visible during slow opponent decisions; per-tick wire cost is zero - Rename misnamed YieldController.autoPassUntilEOT() to autoPassUntilStackEmpty(); add autoPassUntilEndOfTurn() for the legacy field; rewrite currentYieldMessage with explicit branches Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 7 +- .../screens/match/controllers/CPrompt.java | 5 +- .../java/forge/net/HeadlessNetworkClient.java | 2 +- .../forge/net/HeadlessNetworkGuiGame.java | 2 +- .../forge/screens/match/MatchController.java | 7 +- .../src/forge/screens/match/MatchScreen.java | 4 +- .../gamemodes/match/AbstractGuiGame.java | 103 ++++-------------- .../gamemodes/match/YieldController.java | 21 ++-- .../gamemodes/match/input/InputLockUI.java | 25 +++-- .../forge/gamemodes/net/ProtocolMethod.java | 4 +- .../net/server/RemoteClientGuiGame.java | 8 +- .../java/forge/gui/interfaces/IGuiGame.java | 4 + 12 files changed, 67 insertions(+), 125 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 68cf24da2ac..3385a711d54 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -856,8 +856,6 @@ public void updatePhase(boolean saveState) { if (openAbilityMenu != null) { //ensure ability menu can't remain open between phases openAbilityMenu.setVisible(false); } - - checkMarkerAutoClear(); } @Override @@ -1040,7 +1038,7 @@ public void popupMenuCanceled(PopupMenuEvent e) { } @Override - protected void doShowPromptMessage(final PlayerView playerView, final String message) { + public void showPromptMessage(final PlayerView playerView, final String message) { cancelWaitingTimer(); cPrompt.setMessage(message); } @@ -1383,9 +1381,6 @@ private void handleYieldMarkerToggle(final PhaseLabel label, final PlayerView ph controller.selectButtonOk(); } refreshYieldUi(local); - if (clickedSameLabel) { - refreshPromptAfterLocalYieldClear(); - } } @Override diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java index af137ee7582..66d60d8b189 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/controllers/CPrompt.java @@ -64,10 +64,7 @@ public final VPrompt getView() { private Component lastFocusedButton = null; - private final ActionListener actCancel = evt -> { - getMatchUI().clearLocalYieldsForCancel(); - selectButtonCancel(); - }; + private final ActionListener actCancel = evt -> selectButtonCancel(); private final ActionListener actOK = evt -> selectButtonOk(); private final WindowAdapter focusOKButtonOnDialogClose = new WindowAdapter() { diff --git a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java index 12d221f7e64..491617ab770 100644 --- a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java +++ b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkClient.java @@ -394,7 +394,7 @@ public void setGameController(forge.game.player.PlayerView player, IGameControll } @Override - protected void doShowPromptMessage(forge.game.player.PlayerView playerView, String message) { + public void showPromptMessage(forge.game.player.PlayerView playerView, String message) { netLog.info("Prompt: {}", message); // Detect player selection prompts (like "who goes first") diff --git a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java index 6f0083f23ce..7539d2962c3 100644 --- a/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java +++ b/forge-gui-desktop/src/test/java/forge/net/HeadlessNetworkGuiGame.java @@ -74,7 +74,7 @@ public void setGameView(forge.game.GameView gameView) { @Override public void showCombat() { } @Override public void finishGame() { } - @Override protected void doShowPromptMessage(PlayerView playerView, String message) { } + @Override public void showPromptMessage(PlayerView playerView, String message) { } @Override public void showCardPromptMessage(PlayerView playerView, String message, CardView card) { } @Override public void updateButtons(PlayerView owner, String label1, String label2, boolean enable1, boolean enable2, boolean focus1) { } @Override public void flashIncorrectAction() { } diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 03fef48ca6f..25050ee08bf 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -213,7 +213,7 @@ public void buildTouchListeners(final float screenX, final float screenY, final } @Override - protected void doShowPromptMessage(final PlayerView player, final String message) { + public void showPromptMessage(final PlayerView player, final String message) { cancelWaitingTimer(); view.getPrompt(player).setMessage(message); } @@ -282,8 +282,6 @@ public IPaperCard getPaperCard(final String cardName, final String setCode, fina } catch (Exception e) { } } - - checkMarkerAutoClear(); } @@ -608,9 +606,6 @@ private void handleYieldMarkerToggle(final VPhaseIndicator.PhaseLabel label, fin controller.selectButtonOk(); } refreshYieldUi(local); - if (clickedSameLabel) { - refreshPromptAfterLocalYieldClear(); - } } public static void writeMatchPreferences() { diff --git a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java index 4982d852539..ad05839b83b 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchScreen.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchScreen.java @@ -123,7 +123,7 @@ public MatchScreen(List playerPanels0) { bottomPlayerPrompt = add(new VPrompt("", "", e -> getGameController().selectButtonOk(), - e -> { MatchController.instance.clearLocalYieldsForCancel(); getGameController().selectButtonCancel(); })); + e -> getGameController().selectButtonCancel())); if (humanCount < 2 || MatchController.instance.hotSeatMode() || GuiBase.isNetPlay(MatchController.instance)) topPlayerPrompt = null; @@ -131,7 +131,7 @@ public MatchScreen(List playerPanels0) { //show top prompt if multiple human players and not playing in Hot Seat mode and not in network play topPlayerPrompt = add(new VPrompt("", "", e -> getGameController().selectButtonOk(), - e -> { MatchController.instance.clearLocalYieldsForCancel(); getGameController().selectButtonCancel(); })); + e -> getGameController().selectButtonCancel())); topPlayerPrompt.setRotate180(true); topPlayerPanel.setRotate180(true); getHeader().setRotate90(true); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index aed0e93b400..a34aea3a551 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -483,33 +483,13 @@ private String currentYieldMessage() { YieldMarker m = yielding.getAutoPassUntilMarker(); return loc.getMessage("lblYieldingUntilPhaseFmt", m.getPhaseOwner().getName(), m.getPhase().nameForUi); } - if (yielding.autoPassUntilEOT()) { + if (yielding.autoPassUntilStackEmpty()) { return loc.getMessage("lblYieldingUntilStackClears"); } - return loc.getMessage("lblYieldingUntilEndOfTurn"); - } - - private String lastPromptMessage; - - @Override - public final void showPromptMessage(PlayerView playerView, String message) { - lastPromptMessage = message; - doShowPromptMessage(playerView, message); - } - - /** Subclass-specific prompt rendering. The {@code final} wrapper above tracks state so - * {@link #isYieldPromptShowing()} can answer "is the user looking at the auto-pass UI?" - * without relying on cancel-button-label heuristics. */ - protected abstract void doShowPromptMessage(PlayerView playerView, String message); - - /** True when the prompt currently rendered is the auto-pass yield prompt — either alone - * (from {@link #updateAutoPassPrompt}) or with the await line appended (from - * {@link #updatePromptForAwait}). Used by client-side cancel paths to distinguish a - * yield-cancel from an unrelated input cancel that happens to share the cancel button. */ - public boolean isYieldPromptShowing() { - String yieldMsg = currentYieldMessage(); - if (yieldMsg == null || lastPromptMessage == null) return false; - return lastPromptMessage.equals(yieldMsg) || lastPromptMessage.startsWith(yieldMsg + "\n"); + if (yielding.autoPassUntilEndOfTurn()) { + return loc.getMessage("lblYieldingUntilEndOfTurn"); + } + return null; } @Override @@ -547,14 +527,8 @@ private void updateWaitingDisplay(final PlayerView forPlayer, final String waiti timeStr = String.format("%d:%02d", elapsedSec / 60, elapsedSec % 60); } String waiting = Localizer.getInstance().getMessage("lblWaitingForPlayer", waitingForPlayerName) + " (" + timeStr + ")"; - // Preserve the yield prompt above the timer line — otherwise the 1s timer overwrites - // the yield-aware text from updatePromptForAwait. Update lastPromptMessage directly so - // isYieldPromptShowing() stays accurate; route through showPromptMessageNoCancel so we - // don't cancel the timer that's calling us. String yieldMsg = currentYieldMessage(); - String message = yieldMsg != null ? yieldMsg + "\n\n" + waiting : waiting; - lastPromptMessage = message; - showPromptMessageNoCancel(forPlayer, message); + showPromptMessageNoCancel(forPlayer, yieldMsg != null ? yieldMsg + "\n\n" + waiting : waiting); } protected void cancelWaitingTimer() { @@ -617,36 +591,26 @@ public final void updateAutoPassPrompt() { cancelAwaitNextInput(); showPromptMessage(getCurrentPlayer(), message); updateButtons(getCurrentPlayer(), false, true, false); + if (GuiBase.isNetPlay(this)) { + showWaitingTimer(getCurrentPlayer(), findWaitingForPlayerName(getCurrentPlayer())); + } } - /** Called by client-side cancel paths after they've locally cleared yield state and - * shipped the wire update. Drops the stale yield prompt without waiting for the host's - * response so the user sees the correct state immediately. */ - public final void refreshPromptAfterLocalYieldClear() { - updatePromptForAwait(getCurrentPlayer()); + @Override + public void applyYieldUpdate(YieldUpdate update) { + PlayerView pv = yieldUpdatePlayer(update); + if (pv == null) return; + IGameController c = getGameController(pv); + if (c != null) c.applyYieldUpdate(update); + refreshYieldUi(pv); } - /** Cancel-button click handler for the auto-pass UI: clear marker and stack-yield via - * sendYieldUpdate so the host's authoritative state clears regardless of which Input is - * active there (e.g. InputPayMana doesn't route cancel through InputLockUI). Self-applies - * on the client so the chevron and prompt update without waiting for the host response. */ - public final void clearLocalYieldsForCancel() { - if (!isYieldPromptShowing()) return; - PlayerView local = getCurrentPlayer(); - if (local == null) return; - IGameController ctrl = getGameController(local); - if (ctrl == null) return; - YieldController yc = ctrl.getYieldController(); - if (yc == null) return; - if (yc.getAutoPassUntilMarker() != null) { - ctrl.sendYieldUpdate(new YieldUpdate.ClearMarker(local)); - } - if (yc.autoPassUntilEOT()) { - ctrl.sendYieldUpdate(new YieldUpdate.StackYield(local, false)); - } - refreshYieldUi(local); - refreshPromptAfterLocalYieldClear(); + private static PlayerView yieldUpdatePlayer(YieldUpdate update) { + if (update instanceof YieldUpdate.ClearMarker u) return u.player(); + if (update instanceof YieldUpdate.StackYield u) return u.player(); + return null; } + // End auto-yield/input code /** @@ -876,29 +840,4 @@ public void applyDelta(DeltaPacket packet) { // No-op for local games - network implementation is in NetworkGuiGame } - /** - * Phase-change derivation: clear any marker whose target the current phase has reached and - * refresh the chevron + auto-pass prompt. The host's {@link YieldController#shouldAutoYield} - * priority loop refreshes its own UI for the race-loss case where it clears the marker - * before this listener runs. - */ - public final void checkMarkerAutoClear() { - GameView gv = getGameView(); - if (gv == null) return; - boolean wasYieldPromptShowing = isYieldPromptShowing(); - boolean any = false; - for (Map.Entry e : gameControllers.entrySet()) { - YieldController yc = e.getValue().getYieldController(); - if (yc != null && yc.checkAndClearMarker(gv)) { - refreshYieldUi(e.getKey()); - any = true; - } - } - if (!any) return; - if (currentYieldMessage() != null) { - updateAutoPassPrompt(); - } else if (wasYieldPromptShowing) { - updatePromptForAwait(getCurrentPlayer()); - } - } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 136760053a1..5aa6bd62abd 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -60,9 +60,12 @@ public void setSkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) { else set.remove(phase); } - public boolean autoPassUntilEOT() { + public boolean autoPassUntilStackEmpty() { return autoPassUntilStackEmpty; } + public boolean autoPassUntilEndOfTurn() { + return autoPassUntilEOT; + } public YieldMarker getAutoPassUntilMarker() { return autoPassUntilMarker; } @@ -75,6 +78,7 @@ public void setAutoPassUntilEOTWithoutInterruptions(boolean active) { this.autoPassUntilEOT = active; } + // setMarker/clearMarker are mutated from EDT (right-click), Netty (wire receive), and game thread. public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { autoPassUntilEOT = false; if (phaseOwner == null || phase == null) { @@ -111,24 +115,15 @@ public boolean shouldAutoYield() { if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) return true; autoPassUntilStackEmpty = false; } - // Priority-loop fires the marker on the game thread; refresh the chevron here - // because the EDT phase-event listener may already have observed the marker as - // null (race-loss) and skipped its own refresh. if (checkAndClearMarker(gv) && owner != null && owner.getGui() != null) { PlayerView local = owner.getLocalPlayerView(); - if (local != null) owner.getGui().refreshYieldUi(local); + if (local != null) owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(local)); } return autoPassUntilMarker != null; } - /** - * Pure derivation: evaluate fire conditions against the supplied GameView and clear the - * marker if it should fire. Both the host's priority loop and the GUI's phase-event - * listener call this; whichever runs first wins, the other no-ops on null marker. - * - * @return true if this call cleared the marker (caller should refresh UI). - */ - public synchronized boolean checkAndClearMarker(GameView gv) { + /** Game-thread only via {@link #shouldAutoYield}. */ + private boolean checkAndClearMarker(GameView gv) { if (autoPassUntilMarker == null || gv == null) return false; PlayerView turnPlayer = gv.getPlayerTurn(); PhaseType currentPhase = gv.getPhase(); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java index 73d542e6caf..2e8b03ee313 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java @@ -4,6 +4,8 @@ import forge.game.player.Player; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbility; +import forge.gamemodes.match.YieldController; +import forge.gamemodes.match.YieldUpdate; import forge.gui.FThreads; import forge.player.PlayerControllerHuman; import forge.util.ITriggerEvent; @@ -58,9 +60,6 @@ public void run() { private final Runnable showMessageFromEdt = new Runnable() { @Override public void run() { - // While auto-yielding, show the yield prompt with cancel enabled so the user can - // disarm via the cancel button (which routes back to selectButtonCancel below). - // Otherwise show master's "Waiting for actions..." prompt with both buttons disabled. if (controller.getYieldController().shouldAutoYield()) { controller.getGui().updateAutoPassPrompt(); } else { @@ -94,11 +93,21 @@ public void selectButtonOK() { } @Override public void selectButtonCancel() { - // Master pattern: cancel auto-pass for all players. Marker and stack-yield are - // cleared client-side via the cancel-button click path's sendYieldUpdate calls - // (see VPrompt.clearLocalYieldsForCancel), so they don't need to be handled here. - for (final Player player : controller.getGame().getPlayers()) { - player.getController().autoPassCancel(); + // autoPassCancel first: its mayAutoPass gate needs at least one mode still active to do UI updates. + controller.autoPassCancel(); + YieldController yc = controller.getYieldController(); + PlayerView pv = controller.getLocalPlayerView(); + if (yc.getAutoPassUntilMarker() != null) { + yc.clearMarker(); + if (controller.getGui() != null) { + controller.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(pv)); + } + } + if (yc.autoPassUntilStackEmpty()) { + yc.setAutoPassUntilStackEmpty(false); + if (controller.getGui() != null) { + controller.getGui().applyYieldUpdate(new YieldUpdate.StackYield(pv, false)); + } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index 8aab100bf59..44f3146dbcc 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -74,6 +74,8 @@ public enum ProtocolMethod implements IHasForgeLog { showWaitingTimer (Mode.SERVER, Void.TYPE, PlayerView.class, String.class), setHighlighted (Mode.SERVER, Void.TYPE, GameEntityView.class, Boolean.TYPE), applyDelta (Mode.SERVER, Void.TYPE, DeltaPacket.class), + /** Server→client push of authoritative yield-state changes. */ + applyYieldUpdate (Mode.SERVER, Void.TYPE, YieldUpdate.class), // Client -> Server // Note: these should all return void, to avoid awkward situations in @@ -94,7 +96,7 @@ public enum ProtocolMethod implements IHasForgeLog { alphaStrike (Mode.CLIENT, Void.TYPE), reorderHand (Mode.CLIENT, Void.TYPE, CardView.class, Integer.TYPE), requestResync (Mode.CLIENT, Void.TYPE), - sendYieldUpdate (Mode.CLIENT, Void.TYPE, YieldUpdate.class); + sendYieldUpdate (Mode.CLIENT, Void.TYPE, YieldUpdate.class); private enum Mode { SERVER(IGuiGame.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index e296087a13c..2f373fedcec 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -13,6 +13,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.net.DeltaPacket; import forge.gamemodes.net.GameProtocolSender; @@ -270,11 +271,16 @@ public void showCombat() { } @Override - protected void doShowPromptMessage(final PlayerView playerView, final String message) { + public void showPromptMessage(final PlayerView playerView, final String message) { updateGameView(); send(ProtocolMethod.showPromptMessage, playerView, message); } + @Override + public void applyYieldUpdate(final YieldUpdate update) { + send(ProtocolMethod.applyYieldUpdate, update); + } + @Override public void showCardPromptMessage(final PlayerView playerView, final String message, final CardView card) { updateGameView(); diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 7a8dfbbcbc3..bffbca03dbb 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -15,6 +15,7 @@ import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityView; import forge.game.zone.ZoneType; +import forge.gamemodes.match.YieldUpdate; import forge.gamemodes.match.input.InputConfirm; import forge.gamemodes.net.DeltaPacket; import forge.gui.control.PlaybackSpeed; @@ -274,6 +275,9 @@ default List many(final String title, final String topCaption, final int /** Repaint marker chevron / stack-yield UI for the given player. */ default void refreshYieldUi(PlayerView player) {} + /** Apply an authoritative yield-state change. {@link forge.gamemodes.match.AbstractGuiGame} routes to the local {@link forge.interfaces.IGameController}; {@link forge.gamemodes.net.server.RemoteClientGuiGame} forwards over the wire. */ + default void applyYieldUpdate(YieldUpdate update) {} + /** Returns true if this game instance is a network game. */ boolean isNetGame(); void setNetGame(); From 95edf0dcef7d41a2a30bd2a0598181932b3e67b6 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sun, 3 May 2026 13:41:08 +0200 Subject: [PATCH 20/21] Clean up --- .../src/main/java/forge/game/GameView.java | 6 -- .../java/forge/screens/match/CMatchUI.java | 14 +---- .../java/forge/screens/match/VAutoYields.java | 2 +- .../forge/screens/match/MatchController.java | 8 +-- .../screens/match/views/VAutoYields.java | 2 +- .../screens/match/views/VPhaseIndicator.java | 2 +- .../gamemodes/match/AbstractGuiGame.java | 14 ++--- .../forge/gamemodes/match/HostedMatch.java | 2 +- .../gamemodes/match/YieldController.java | 58 ++++++------------ .../forge/gamemodes/match/YieldUpdate.java | 3 - .../gamemodes/match/input/InputLockUI.java | 2 +- .../net/client/NetGameController.java | 36 ++++------- .../java/forge/gui/interfaces/IGuiGame.java | 2 +- .../forge/interfaces/IGameController.java | 2 - .../java/forge/player/AutoYieldStore.java | 2 +- .../forge/player/PlayerControllerHuman.java | 60 +++++++------------ 16 files changed, 70 insertions(+), 145 deletions(-) diff --git a/forge-game/src/main/java/forge/game/GameView.java b/forge-game/src/main/java/forge/game/GameView.java index e8a35bd092d..26b527f0bb4 100644 --- a/forge-game/src/main/java/forge/game/GameView.java +++ b/forge-game/src/main/java/forge/game/GameView.java @@ -137,10 +137,6 @@ void updateStack(final MagicStack stack) { set(TrackableProperty.StormCount, stack.getSpellsCastThisTurn().size()); } - public boolean isFirstGameInMatch() { - return getNumPlayedGamesInMatch() == 0; - } - public int getNumPlayedGamesInMatch() { return get(TrackableProperty.NumPlayedGamesInMatch); } @@ -154,8 +150,6 @@ public boolean isMatchOver() { } public boolean isMulligan() { - if (get(TrackableProperty.Mulligan) == null) - return false; return get(TrackableProperty.Mulligan); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 3d624e83867..d414fae2253 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -204,10 +204,6 @@ private void registerDocs() { } } - private static boolean isPreferenceEnabled(final ForgePreferences.FPref preferenceName) { - return FModel.getPreferences().getPrefBoolean(preferenceName); - } - FScreen getScreen() { return this.screen; } @@ -244,15 +240,11 @@ public void refreshYieldUi(final PlayerView player) { FThreads.invokeInEdtNowOrLater(() -> { // Marker is rendered only on the local player's view of the targeted phase indicator. PlayerView local = getCurrentPlayer(); - if (local == null || !local.equals(player)) { + if (!player.equals(local)) { return; } for (final VField f : getFieldViews()) { - PhaseIndicator pi = f.getPhaseIndicator(); - if (pi == null) { - continue; - } - for (PhaseLabel l : pi.allLabels()) { + for (PhaseLabel l : f.getPhaseIndicator().allLabels()) { l.setYieldMarked(false); } } @@ -467,7 +459,7 @@ else if (selectedDocBeforeCombat != null) { //re-select doc that was selected be // Combat pairings changed — rebuild layout so grouping reflects them if (!"default".equals(FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS)) - || isPreferenceEnabled(FPref.UI_SEPARATE_COMBAT_STACKS)) { + || FModel.getPreferences().getPrefBoolean(FPref.UI_SEPARATE_COMBAT_STACKS)) { FThreads.invokeInEdtNowOrLater(() -> { for (final VField f : getFieldViews()) { f.getTabletop().doLayout(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYields.java b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYields.java index 1863f9e8e46..7070c4e5915 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYields.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/VAutoYields.java @@ -34,7 +34,7 @@ public VAutoYields(final CMatchUI matchUI) { setTitle(Localizer.getInstance().getMessage("lblAutoYields")); autoYields = new ArrayList<>(); - for (final String autoYield : matchUI.getGameController().getAutoYields()) { + for (final String autoYield : matchUI.getGameController().getYieldController().getAutoYields()) { autoYields.add(autoYield); } lstAutoYields = new FList<>(new AutoYieldsListModel()); diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 25050ee08bf..9480eb49608 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -734,15 +734,11 @@ public void refreshYieldUi(final PlayerView player) { } // Marker only rendered for the local player's view. PlayerView local = getCurrentPlayer(); - if (local == null || !local.equals(player)) { + if (!player.equals(local)) { return; } for (final VPlayerPanel panel : view.getPlayerPanelsList()) { - VPhaseIndicator pi = panel.getPhaseIndicator(); - if (pi == null) { - continue; - } - for (VPhaseIndicator.PhaseLabel l : pi.allLabels()) { + for (VPhaseIndicator.PhaseLabel l : panel.getPhaseIndicator().allLabels()) { l.setYieldMarked(false); } } diff --git a/forge-gui-mobile/src/forge/screens/match/views/VAutoYields.java b/forge-gui-mobile/src/forge/screens/match/views/VAutoYields.java index f4bda9b8938..6d69c04b8ce 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VAutoYields.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VAutoYields.java @@ -18,7 +18,7 @@ public class VAutoYields extends FDialog { public VAutoYields() { super(Forge.getLocalizer().getMessage("lblAutoYields"), 2); List autoYields = new ArrayList<>(); - for (String autoYield : MatchController.instance.getGameController().getAutoYields()) { + for (String autoYield : MatchController.instance.getGameController().getYieldController().getAutoYields()) { autoYields.add(autoYield); } lstAutoYields = add(new FChoiceList(autoYields) { diff --git a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java index 63e5db048e5..9c166e6632b 100644 --- a/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java +++ b/forge-gui-mobile/src/forge/screens/match/views/VPhaseIndicator.java @@ -207,7 +207,7 @@ else if (active && !stopAtPhase) { } private void drawChevron(final Graphics g, float x, float w, float h) { - // Two back-to-back triangles centered in the cell, mirroring desktop. + // Two back-to-back triangles centered in the cell float size = Math.max(Utils.scale(6f), h * 0.55f); float cx = x + (w - size) / 2f; float cy = (h - size) / 2f; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index 8e1cb57c93a..2bc7f4fb5df 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -1,6 +1,7 @@ package forge.gamemodes.match; import com.google.common.collect.*; + import forge.game.GameEntityView; import forge.game.GameEndReason; import forge.game.GameLog; @@ -25,6 +26,7 @@ import forge.trackable.TrackableTypes; import forge.util.FSerializableFunction; import forge.util.Localizer; + import org.apache.commons.lang3.StringUtils; import java.io.Serializable; @@ -621,19 +623,15 @@ public final void updateAutoPassPrompt() { @Override public void applyYieldUpdate(YieldUpdate update) { - PlayerView pv = yieldUpdatePlayer(update); - if (pv == null) return; + PlayerView pv; + if (update instanceof YieldUpdate.ClearMarker u) pv = u.player(); + else if (update instanceof YieldUpdate.StackYield u) pv = u.player(); + else return; IGameController c = getGameController(pv); if (c != null) c.applyYieldUpdate(update); refreshYieldUi(pv); } - private static PlayerView yieldUpdatePlayer(YieldUpdate update) { - if (update instanceof YieldUpdate.ClearMarker u) return u.player(); - if (update instanceof YieldUpdate.StackYield u) return u.player(); - return null; - } - // End auto-yield/input code /** diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 63013c6780e..1f1b198a92b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -387,7 +387,7 @@ public void endCurrentGame() { ngg.shutdownForwarder(); } humanController.getGui().setGameSpeed(PlaybackSpeed.NORMAL); - humanController.clearAutoYields(); + humanController.getYieldController().clearAutoYields(); if (humanCount > 0) //conceded humanController.getGui().afterGameEnd(); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 5aa6bd62abd..be8a7838556 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -33,8 +33,8 @@ public class YieldController { private final PlayerControllerHuman owner; - private boolean autoPassUntilStackEmpty; private boolean autoPassUntilEOT; + private boolean autoPassUntilStackEmpty; private YieldMarker autoPassUntilMarker; /** Priority has passed through any non-target phase since marker activation. */ @@ -91,7 +91,6 @@ public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boole hasLeftMarker = !atOrPastAtClick; activationOnMarker = atOrPastAtClick; } - public synchronized void clearMarker() { autoPassUntilMarker = null; hasLeftMarker = false; @@ -100,10 +99,8 @@ public synchronized void clearMarker() { /** Click-site helper: true when priority is at or past {@code phase} on {@code phaseOwner}'s current turn. */ public static boolean isPriorityAtOrPastMarker(GameView gv, PlayerView phaseOwner, PhaseType phase) { - if (gv == null || phaseOwner == null || phase == null) return false; - PlayerView turnPlayer = gv.getPlayerTurn(); PhaseType currentPhase = gv.getPhase(); - if (turnPlayer == null || !turnPlayer.equals(phaseOwner)) return false; + if (!phaseOwner.equals(gv.getPlayerTurn())) return false; if (currentPhase == null) return false; return currentPhase == phase || currentPhase.isAfter(phase); } @@ -112,41 +109,32 @@ public boolean shouldAutoYield() { if (autoPassUntilEOT) return true; GameView gv = owner != null && owner.getGui() != null ? owner.getGui().getGameView() : null; if (autoPassUntilStackEmpty) { - if (gv != null && gv.getStack() != null && !gv.getStack().isEmpty()) return true; + if (gv != null && gv.peekStack() != null) return true; autoPassUntilStackEmpty = false; } - if (checkAndClearMarker(gv) && owner != null && owner.getGui() != null) { - PlayerView local = owner.getLocalPlayerView(); - if (local != null) owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(local)); + if (autoPassUntilMarker != null && gv != null) { + PlayerView turnPlayer = gv.getPlayerTurn(); + PhaseType currentPhase = gv.getPhase(); + boolean inMarkerOwnerTurn = autoPassUntilMarker.getPhaseOwner().equals(turnPlayer); + boolean atTarget = inMarkerOwnerTurn && currentPhase == autoPassUntilMarker.getPhase(); + boolean pastTarget = inMarkerOwnerTurn && currentPhase != null && currentPhase.isAfter(autoPassUntilMarker.getPhase()); + if (hasLeftMarker && (atTarget || (!activationOnMarker && pastTarget))) { + clearMarker(); + PlayerView local = owner.getLocalPlayerView(); + if (local != null) owner.getGui().applyYieldUpdate(new YieldUpdate.ClearMarker(local)); + } + if (!atTarget && !hasLeftMarker) hasLeftMarker = true; } return autoPassUntilMarker != null; } - /** Game-thread only via {@link #shouldAutoYield}. */ - private boolean checkAndClearMarker(GameView gv) { - if (autoPassUntilMarker == null || gv == null) return false; - PlayerView turnPlayer = gv.getPlayerTurn(); - PhaseType currentPhase = gv.getPhase(); - boolean inMarkerOwnerTurn = turnPlayer != null && turnPlayer.equals(autoPassUntilMarker.getPhaseOwner()); - boolean atTarget = inMarkerOwnerTurn && currentPhase == autoPassUntilMarker.getPhase(); - boolean pastTarget = inMarkerOwnerTurn && currentPhase != null - && autoPassUntilMarker.getPhase() != null && currentPhase.isAfter(autoPassUntilMarker.getPhase()); - boolean shouldFire = hasLeftMarker && (atTarget || (!activationOnMarker && pastTarget)); - if (shouldFire) { - clearMarker(); - return true; - } - if (!atTarget && !hasLeftMarker) hasLeftMarker = true; - return false; - } - // ---- Auto-yield (per-card/ability) and trigger decisions ---- public boolean shouldAutoYield(String key) { AutoYieldStore store = activeStore(); if (store.isDisabled()) return false; if (!tierAware()) { - // Cache: keys stored at storageKey shape (full or stripped). Check both. + // Cache: keys stored at storageKey shape (full or stripped) return store.shouldYield(AutoYieldStore.Tier.GAME, key) || store.shouldYield(AutoYieldStore.Tier.GAME, AutoYieldStore.abilitySuffix(key)); } @@ -187,8 +175,7 @@ private boolean tierAware() { } private static boolean activeModeIsInstall() { - return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals( - FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); + return ForgeConstants.AUTO_YIELD_PER_ABILITY_INSTALL.equals(FModel.getPreferences().getPref(FPref.UI_AUTO_YIELD_MODE)); } private static AutoYieldStore.Tier activeTier() { @@ -229,7 +216,6 @@ public void setDisableAutoYields(boolean disable) { public boolean shouldAlwaysAcceptTrigger(int trigger) { return activeStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.ACCEPT; } - public boolean shouldAlwaysDeclineTrigger(int trigger) { return activeStore().getTriggerDecision(trigger) == AutoYieldStore.TriggerDecision.DECLINE; } @@ -237,11 +223,9 @@ public boolean shouldAlwaysDeclineTrigger(int trigger) { public void setAlwaysAcceptTrigger(int trigger) { activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT); } - public void setAlwaysDeclineTrigger(int trigger) { activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE); } - public void setAlwaysAskTrigger(int trigger) { activeStore().setTriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK); } @@ -261,8 +245,7 @@ public YieldStateSnapshot buildClientSnapshot(Map } // Trigger decisions are per-game; deltas flow during play. Map triggers = new HashMap<>(); - return new YieldStateSnapshot(cardYields, abilityYields, triggers, getDisableAutoYields(), - skipPhases == null ? new HashMap<>() : skipPhases); + return new YieldStateSnapshot(cardYields, abilityYields, triggers, getDisableAutoYields(), skipPhases); } /** Atomic seed of client-persistent state at game start or reconnection. Cache mode only. */ @@ -274,9 +257,6 @@ public void applyClientSeed(YieldStateSnapshot snap) { localStore.setTriggerDecision(e.getKey(), e.getValue()); } localStore.setDisabled(snap.autoYieldsDisabled()); - skipPhases.clear(); - for (Map.Entry> e : snap.skipPhases().entrySet()) { - skipPhases.put(e.getKey(), EnumSet.copyOf(e.getValue())); - } + skipPhases.putAll(snap.skipPhases()); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java index 4a8e6da1293..09096e6cdcc 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldUpdate.java @@ -8,8 +8,6 @@ /** * Unified envelope for all yield-related sync between client and host. - * One sealed type with seven cases replaces eight per-method ProtocolMethod - * entries. Both directions (CLIENT->HOST, HOST->CLIENT) ride this envelope. * * Receiver dispatches via exhaustive switch in PlayerControllerHuman * (host-side) and NetworkGuiGame (client-side). @@ -36,6 +34,5 @@ record CardAutoYield(String cardKey, boolean active, boolean abilityScope) imple record SkipPhase(PlayerView turnPlayer, PhaseType phase, boolean skip) implements YieldUpdate {} - /** Atomic snapshot of client's persistent yield state. Sent by client at game start and reconnection only - not host->client. */ record SeedFromClient(YieldStateSnapshot snapshot) implements YieldUpdate {} } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java index 2e8b03ee313..2b02c874848 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputLockUI.java @@ -60,7 +60,7 @@ public void run() { private final Runnable showMessageFromEdt = new Runnable() { @Override public void run() { - if (controller.getYieldController().shouldAutoYield()) { + if (controller.mayAutoPass()) { controller.getGui().updateAutoPassPrompt(); } else { controller.getGui().updateButtons(InputLockUI.this.getOwner(), "", "", false, false, false); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java index 8fb57b983f1..62978501ed5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/NetGameController.java @@ -149,23 +149,11 @@ public boolean shouldAutoYield(final String key) { @Override public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { String storageKey = yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); - send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.CardAutoYield(storageKey, autoYield, isAbilityScope)); - } - - @Override - public Iterable getAutoYields() { - return yieldController.getAutoYields(); - } - - @Override - public void clearAutoYields() { - // No-op locally: tier lifecycle is driven separately. Server-side mirror is cleared by HostedMatch. + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.CardAutoYield(storageKey, autoYield, isAbilityScope)); } @Override public boolean getDisableAutoYields() { return yieldController.getDisableAutoYields(); } - @Override public void setDisableAutoYields(final boolean disable) { yieldController.setDisableAutoYields(disable); } @@ -173,7 +161,6 @@ public void clearAutoYields() { public boolean shouldAlwaysAcceptTrigger(final int trigger) { return yieldController.shouldAlwaysAcceptTrigger(trigger); } - @Override public boolean shouldAlwaysDeclineTrigger(final int trigger) { return yieldController.shouldAlwaysDeclineTrigger(trigger); @@ -182,30 +169,19 @@ public boolean shouldAlwaysDeclineTrigger(final int trigger) { @Override public void setShouldAlwaysAcceptTrigger(final int trigger) { yieldController.setAlwaysAcceptTrigger(trigger); - send(ProtocolMethod.sendYieldUpdate, - new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.ACCEPT)); } - @Override public void setShouldAlwaysDeclineTrigger(final int trigger) { yieldController.setAlwaysDeclineTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.DECLINE)); } - @Override public void setShouldAlwaysAskTrigger(final int trigger) { yieldController.setAlwaysAskTrigger(trigger); send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.TriggerDecision(trigger, AutoYieldStore.TriggerDecision.ASK)); } - /** - * Build a YieldStateSnapshot from the local persistent yield state plus the - * GUI-loaded skip-phase prefs and ship it to the host in one wire message. - */ - public void seedYieldStateOnHost(Map> skipPhases) { - send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); - } - public void setUiShouldSkipPhase(final PlayerView turnPlayer, final PhaseType phase, final boolean shouldSkip) { send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SkipPhase(turnPlayer, phase, shouldSkip)); } @@ -234,6 +210,14 @@ public void sendYieldUpdate(final YieldUpdate update) { send(ProtocolMethod.sendYieldUpdate, update); } + /** + * Build a YieldStateSnapshot from the local persistent yield state plus the + * GUI-loaded skip-phase prefs and ship it to the host in one wire message. + */ + public void seedYieldStateOnHost(Map> skipPhases) { + send(ProtocolMethod.sendYieldUpdate, new YieldUpdate.SeedFromClient(yieldController.buildClientSnapshot(skipPhases))); + } + private IMacroSystem macros; @Override public IMacroSystem macros() { diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index bffbca03dbb..514917f447b 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -276,7 +276,7 @@ default List many(final String title, final String topCaption, final int default void refreshYieldUi(PlayerView player) {} /** Apply an authoritative yield-state change. {@link forge.gamemodes.match.AbstractGuiGame} routes to the local {@link forge.interfaces.IGameController}; {@link forge.gamemodes.net.server.RemoteClientGuiGame} forwards over the wire. */ - default void applyYieldUpdate(YieldUpdate update) {} + void applyYieldUpdate(YieldUpdate update); /** Returns true if this game instance is a network game. */ boolean isNetGame(); diff --git a/forge-gui/src/main/java/forge/interfaces/IGameController.java b/forge-gui/src/main/java/forge/interfaces/IGameController.java index 4ec02f6267e..d10bb1032d8 100644 --- a/forge-gui/src/main/java/forge/interfaces/IGameController.java +++ b/forge-gui/src/main/java/forge/interfaces/IGameController.java @@ -61,8 +61,6 @@ public interface IGameController { * route storage by this flag instead of consulting the host's own UI_AUTO_YIELD_MODE. */ void setShouldAutoYield(String key, boolean autoYield, boolean isAbilityScope); - Iterable getAutoYields(); - void clearAutoYields(); boolean getDisableAutoYields(); void setDisableAutoYields(boolean disable); diff --git a/forge-gui/src/main/java/forge/player/AutoYieldStore.java b/forge-gui/src/main/java/forge/player/AutoYieldStore.java index 29855241e0a..fce4832a2e4 100644 --- a/forge-gui/src/main/java/forge/player/AutoYieldStore.java +++ b/forge-gui/src/main/java/forge/player/AutoYieldStore.java @@ -29,13 +29,13 @@ public void setYield(Tier tier, String key, boolean autoYield) { } public Iterable getYields(Tier tier) { return yieldsByTier.get(tier); } + public boolean isDisabled() { return disabled; } public void setDisabled(boolean disabled) { this.disabled = disabled; } public TriggerDecision getTriggerDecision(int triggerId) { return triggerDecisions.getOrDefault(triggerId, TriggerDecision.ASK); } - public void setTriggerDecision(int triggerId, TriggerDecision decision) { if (decision == TriggerDecision.ASK) triggerDecisions.remove(triggerId); else triggerDecisions.put(triggerId, decision); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 99c915c2599..14c8e631eb5 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -2365,7 +2365,7 @@ public boolean tryUndoLastAction() { // ensure prompt updated if needed currentInput.showMessageInitial(); } - if (gui.isNetGame()) { + if (getGui().isNetGame()) { // Flush events to remote clients — the undo modifies game state // (untaps lands, etc.) after the prompt is shown, and without this // the updated state sits in the forwarder buffer until the next action. @@ -3341,31 +3341,6 @@ public void concede() { } } - public boolean mayAutoPass() { - return yieldController.shouldAutoYield(); - } - - public void autoPassUntilEndOfTurn() { - yieldController.setAutoPassUntilEOTWithoutInterruptions(true); - if (getGui() != null) { - getGui().updateAutoPassPrompt(); - } - } - - @Override - public void autoPassCancel() { - if (!mayAutoPass()) { - return; - } - yieldController.setAutoPassUntilEOTWithoutInterruptions(false); - if (getGui() != null) { - PlayerView playerView = getLocalPlayerView(); - getGui().showPromptMessage(playerView, ""); - getGui().updateButtons(playerView, false, false, false); - getGui().awaitNextInput(); - } - } - @Override public void awaitNextInput() { getGui().awaitNextInput(); @@ -3487,23 +3462,34 @@ public boolean isRemoteClient() { return gui instanceof forge.gamemodes.net.server.RemoteClientGuiGame; } - @Override - public boolean shouldAutoYield(final String key) { - return yieldController.shouldAutoYield(key); + public boolean mayAutoPass() { + return yieldController.shouldAutoYield(); } - @Override - public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { - yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); + + public void autoPassUntilEndOfTurn() { + yieldController.setAutoPassUntilEOTWithoutInterruptions(true); + getGui().updateAutoPassPrompt(); } @Override - public Iterable getAutoYields() { - return yieldController.getAutoYields(); + public void autoPassCancel() { + if (!mayAutoPass()) { + return; + } + yieldController.setAutoPassUntilEOTWithoutInterruptions(false); + PlayerView playerView = getLocalPlayerView(); + getGui().showPromptMessage(playerView, ""); + getGui().updateButtons(playerView, false, false, false); + getGui().awaitNextInput(); } @Override - public void clearAutoYields() { - yieldController.clearAutoYields(); + public boolean shouldAutoYield(final String key) { + return yieldController.shouldAutoYield(key); + } + @Override + public void setShouldAutoYield(final String key, final boolean autoYield, final boolean isAbilityScope) { + yieldController.setShouldAutoYield(key, autoYield, isAbilityScope); } @Override @@ -3571,7 +3557,7 @@ public void applyYieldUpdate(final YieldUpdate update) { } else if (update instanceof YieldUpdate.SeedFromClient u) { yieldController.applyClientSeed(u.snapshot()); } - if (activatedYield && getGui() != null) { + if (activatedYield) { // Switch the cancel button + prompt to "Yielding until X" so the user can disarm. // Otherwise the previous InputPassPriority "End Turn" label would persist and ESC // would skip the click on the client. From 9483328a3fd24d7a071aaa55e7064133cd666da6 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sun, 3 May 2026 13:42:32 +0200 Subject: [PATCH 21/21] Clean up --- .../src/main/java/forge/gamemodes/match/YieldController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index be8a7838556..9cc26533bb8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -257,6 +257,7 @@ public void applyClientSeed(YieldStateSnapshot snap) { localStore.setTriggerDecision(e.getKey(), e.getValue()); } localStore.setDisabled(snap.autoYieldsDisabled()); + skipPhases.clear(); skipPhases.putAll(snap.skipPhases()); } }