diff --git a/forge-game/src/main/java/forge/game/GameAction.java b/forge-game/src/main/java/forge/game/GameAction.java index 81dce2ff9ff..2dcc6ae0a44 100644 --- a/forge-game/src/main/java/forge/game/GameAction.java +++ b/forge-game/src/main/java/forge/game/GameAction.java @@ -2207,6 +2207,7 @@ public void revealTo(final CardCollectionView cards, final Iterable to, final ZoneType zone = cards.getFirst().getZone().getZoneType(); final Player owner = cards.getFirst().getOwner(); + logReveal(cards, zone, owner); for (final Player p : to) { p.getController().reveal(cards, zone, owner, messagePrefix, addSuffix); } @@ -2233,6 +2234,7 @@ public void reveal(CardCollectionView cards, ZoneType zt, Player cardOwner, bool reveal(cards, zt, cardOwner, dontRevealToOwner, messagePrefix, true); } public void reveal(CardCollectionView cards, ZoneType zt, Player cardOwner, boolean dontRevealToOwner, String messagePrefix, boolean msgAddSuffix) { + logReveal(cards, zt, cardOwner); for (Player p : game.getPlayers()) { if (dontRevealToOwner && cardOwner == p) { continue; @@ -2241,6 +2243,18 @@ public void reveal(CardCollectionView cards, ZoneType zt, Player cardOwner, bool } } + /** Add a single game-log line naming the revealed cards and the zone they came from. + * Fired once here (not per viewer) so a hand reveal is one entry, not one-per-card or + * one-per-opponent. Gated for display by the REVEAL verbosity category. */ + private void logReveal(final CardCollectionView cards, final ZoneType zone, final Player owner) { + if (cards.isEmpty()) { + return; + } + game.fireEvent(new GameEventAddLog(GameLogEntryType.REVEAL, + Localizer.getInstance().getMessage("lblRevealLogEntry", + Lang.joinHomogenous(cards), owner, zone.getTranslatedName().toLowerCase()))); + } + public void revealUnplayableByAI(String title, Map>> unplayableCards) { // Notify both players for (Player p : game.getPlayers()) { diff --git a/forge-game/src/main/java/forge/game/GameLogEntryType.java b/forge-game/src/main/java/forge/game/GameLogEntryType.java index cc5cd61e85c..03458b07b0f 100644 --- a/forge-game/src/main/java/forge/game/GameLogEntryType.java +++ b/forge-game/src/main/java/forge/game/GameLogEntryType.java @@ -8,6 +8,7 @@ public enum GameLogEntryType { ANTE("Ante"), DRAFT("Draft"), ZONE_CHANGE("Zone Change"), + REVEAL("Reveal"), PLAYER_CONTROL("Player Control"), DAMAGE("Damage"), LIFE("Life"), diff --git a/forge-game/src/main/java/forge/game/GameLogVerbosity.java b/forge-game/src/main/java/forge/game/GameLogVerbosity.java index 23d346ad180..60915c2e92a 100644 --- a/forge-game/src/main/java/forge/game/GameLogVerbosity.java +++ b/forge-game/src/main/java/forge/game/GameLogVerbosity.java @@ -12,7 +12,8 @@ public enum GameLogVerbosity { EnumSet.of(GameLogEntryType.GAME_OUTCOME, GameLogEntryType.MATCH_RESULTS, GameLogEntryType.TURN, GameLogEntryType.MULLIGAN, GameLogEntryType.ANTE, GameLogEntryType.DAMAGE, - GameLogEntryType.ZONE_CHANGE, GameLogEntryType.LAND, + GameLogEntryType.ZONE_CHANGE, GameLogEntryType.REVEAL, + GameLogEntryType.LAND, GameLogEntryType.DISCARD, GameLogEntryType.COMBAT, GameLogEntryType.STACK_ADD, GameLogEntryType.STACK_RESOLVE, GameLogEntryType.LIFE)), diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index fb47f9c01d0..1af9e46c736 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -2124,6 +2124,7 @@ lblGainControl=gain control. lblReturnToHand=return to hand. lbldiscard=discard. lblReveal=reveal +lblRevealLogEntry={0} revealed from {1}'s {2} lblTap=tap lblCurrentCard=Current Card lblSelectNSpecifyTypeCardsToAction=Select %d {0} card(s) to {1} 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 c6514e14232..44425e38195 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -5,6 +5,7 @@ import forge.game.card.CardView; import forge.game.combat.CombatView; import forge.game.phase.PhaseType; +import forge.game.player.Player; import forge.game.player.PlayerView; import forge.game.spellability.SpellAbilityStackInstance; import forge.game.spellability.StackItemView; @@ -140,6 +141,11 @@ public synchronized void setAutoPassUntilEndOfTurn(boolean active) { this.autoPassUntilEOT = active; } public synchronized void setMarker(PlayerView phaseOwner, PhaseType phase, boolean atOrPastAtClick) { + if (phaseOwner == null || phase == null) { + // Wire envelopes from an unhealthy client can land here; treat as a clear rather than store a junk marker. + clearMarker(); + return; + } autoPassUntilEOT = false; autoPassUntilStackEmpty = false; stackYieldRespectsInterrupts = false; @@ -470,7 +476,9 @@ public void onSpellAbilityCast(SpellAbilityStackInstance si) { if (!shouldEvaluateInterrupts()) return; PlayerView local = owner.getLocalPlayerView(); if (local == null) return; - boolean isOpponent = !si.getActivatingPlayer().getView().equals(local); + // Triggered abilities and emblem-sourced spells can land here with a null activator. + Player activator = si.getActivatingPlayer(); + boolean isOpponent = activator != null && !activator.getView().equals(local); if (isOpponent && getBoolPref(FPref.YIELD_INTERRUPT_ON_OPPONENT_SPELL)) { applyInterrupt(); @@ -501,7 +509,10 @@ public void onAttackersDeclared(CombatView combat) { } } - private boolean shouldEvaluateInterrupts() { + /** True when an event-driven interrupt should be considered: either an interruptible + * yield is active, or APINA + RESPECTS_INTERRUPTS are both on (so the next priority + * window's auto-pass can be blocked by autoPassInterrupted). */ + public boolean shouldEvaluateInterrupts() { if (isInterruptibleYieldActive()) return true; return getBoolPref(FPref.YIELD_AUTO_PASS_NO_ACTIONS) && getBoolPref(FPref.YIELD_AUTO_PASS_RESPECTS_INTERRUPTS); } diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index 2dad935656a..196bd1c99fd 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -282,7 +282,9 @@ public Void visit(final GameEventSpellAbilityCast event) { private void evaluateYieldInterruptForSpellCast(GameEventSpellAbilityCast event) { if (humanController == null) return; YieldController yc = humanController.getYieldController(); - if (!yc.isYieldActive()) return; + // isYieldActive() only covers explicit yields; APINA-with-respects-interrupts + // also wants to be told about casts so it can set autoPassInterrupted. + if (!yc.shouldEvaluateInterrupts()) return; GameView gv = matchController.getGameView(); if (gv == null || gv.getGame() == null) return; // Look up the actual SpellAbilityStackInstance by id (host-side; client gv.getGame() is null). @@ -375,7 +377,8 @@ public Void visit(final GameEventBlockersDeclared event) { public Void visit(final GameEventAttackersDeclared event) { if (humanController != null) { YieldController yc = humanController.getYieldController(); - if (yc.isYieldActive()) { + // APINA-with-respects-interrupts wants the attackers signal too, not just explicit yields. + if (yc.shouldEvaluateInterrupts()) { GameView gv = matchController.getGameView(); if (gv != null && gv.getCombat() != null) yc.onAttackersDeclared(gv.getCombat()); } diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index 47c641056c7..04a3f44ad0a 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -957,6 +957,12 @@ protected void reveal(final CardCollectionView cards, final ZoneType zone, final message += " " + localizer.getMessage("lblPlayerZone", "{player's}", zone.getTranslatedName().toLowerCase()); } final String fm = MessageUtil.formatMessage(message, getLocalPlayerView(), owner); + // While yielding with the reveal interrupt off, skip the modal the auto-pass would just + // plow through. The reveal is still recorded in the game log (GameAction logs it once, + // independent of the modal), so the information isn't lost. + if (shouldSuppressRevealModal()) { + return; + } if (cards.isEmpty()) { getGui().message(MessageUtil.formatMessage(localizer.getMessage("lblThereNoCardInPlayerZone", "{player's}", zone.getTranslatedName().toLowerCase()), getLocalPlayerView(), owner), fm); @@ -1890,19 +1896,38 @@ public void notifyOfValue(final SpellAbility sa, final GameObject realtedTarget, final String message = MessageUtil.formatNotificationMessage(sa, player, realtedTarget, value); if (sa != null && sa.isManaAbility()) { getGame().fireEvent(new GameEventAddLog(GameLogEntryType.LAND, message)); - } else if (sa != null && sa.getHostCard() != null && getGui().isLibgdxPort()) { - CardView cardView; - IPaperCard iPaperCard = sa.getHostCard().getPaperCard(); - if (iPaperCard != null) - cardView = CardView.getCardForUi(iPaperCard); - else - cardView = sa.getHostCard().getView(); - getGui().confirm(cardView, message, true, ImmutableList.of(localizer.getMessage("lblOK"))); } else { - getGui().message(message, sa == null || sa.getHostCard() == null ? "" : CardView.get(sa.getHostCard()).toString()); + // notifyOfValue carries many non-reveal messages (coin flips, vote results, + // "Attack declaration invalid", ...), some important, so it is never suppressed. + if (sa != null && sa.getHostCard() != null && getGui().isLibgdxPort()) { + CardView cardView; + IPaperCard iPaperCard = sa.getHostCard().getPaperCard(); + if (iPaperCard != null) + cardView = CardView.getCardForUi(iPaperCard); + else + cardView = sa.getHostCard().getView(); + getGui().confirm(cardView, message, true, ImmutableList.of(localizer.getMessage("lblOK"))); + } else { + getGui().message(message, sa == null || sa.getHostCard() == null ? "" : CardView.get(sa.getHostCard()).toString()); + } } } + /** + * True when a card-reveal modal should be skipped while yielding: + * INTERRUPT_ON_REVEAL is off (if it were on, the reveal just interrupted, so + * the modal is meaningful) and {@link #mayAutoPass} confirms we'd auto-pass + * past it anyway (i.e. an explicit yield is active or APINA would fire — both + * of which already filter for autoPassInterrupted). Skipping is inherent to + * having the reveal interrupt off; there's no separate pref. Want the reveals? + * Turn on the reveal interrupt (with APINA-respects-interrupts). Only gates the + * reveal modal — the reveal is still written to the game log either way. + */ + private boolean shouldSuppressRevealModal() { + if (yieldController.getBoolPref(FPref.YIELD_INTERRUPT_ON_REVEAL)) return false; + return mayAutoPass(); + } + /* * (non-Javadoc) *