From e328da066e467a2de82f0bc1e2b71cdfad72f471 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 10 May 2026 07:25:54 +0930 Subject: [PATCH 1/4] Fix remote player not seeing Jhoira choice dialog Skip IdRef replacement for CardViews absent from the host tracker so ephemerals (e.g. Jhoira choice copies that never enter a tracked zone) serialize natively. The receiver registers them in its tracker on arrival so subsequent IdRef references resolve correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gamemodes/net/TrackableSerializer.java | 16 +++++++++++++++- .../gamemodes/net/client/GameClientHandler.java | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 4219c6339a8..50ed7888617 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -65,16 +65,30 @@ static TrackableType trackableTypeFor(byte typeTag) { * set so the receiver decodes a detached CardView from the carried name * and image key. When {@code tracker} is null, the snapshot check is * skipped (used by the client encoder, which has no game-state awareness). + * + *

In non-event mode, CardViews missing from the tracker pass through + * unchanged so Java serializes the full object inline (covers ephemeral + * choice copies that never enter a tracked zone). */ static Object replace(Object obj, Tracker tracker, boolean eventMode) { if (obj instanceof TrackableObject trackable) { byte tag = typeTagFor(trackable); if (tag < 0) return obj; - if (!eventMode || tag == TYPE_PLAYER_VIEW) { + if (tag == TYPE_PLAYER_VIEW) { return new IdRef(tag, trackable.getId()); } + if (!eventMode) { + if (tracker != null) { + TrackableType type = trackableTypeFor(tag); + if (type != null && tracker.getObj(type, trackable.getId()) != null) { + return new IdRef(tag, trackable.getId()); + } + } + return obj; // ephemeral or tracker-less encoder — serialize the full object + } + boolean preserveSnapshot = false; if (tracker != null) { TrackableType type = trackableTypeFor(tag); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index aea0d0a0b4e..a21549fcc81 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -126,7 +126,9 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod /** * This method is used to recursively update the tracker - * references on all objects and their props. + * references on all objects and their props, and register them in the + * id lookup so inline-serialized CardViews are findable by IdRef + * in subsequent messages. * * @param objs */ @@ -135,6 +137,7 @@ private void updateTrackers(final Object[] objs) { if (obj instanceof TrackableObject trackableObject) { if (trackableObject.getTracker() == null) { trackableObject.setTracker(this.tracker); + registerInTracker(trackableObject); // walk the props EnumMap props = trackableObject.getProps(); if (props != null) { @@ -153,6 +156,18 @@ private void updateTrackers(final Object[] objs) { } } + private void registerInTracker(final TrackableObject obj) { + if (obj instanceof CardView cv) { + if (tracker.getObj(TrackableTypes.CardViewType, cv.getId()) == null) { + tracker.putObj(TrackableTypes.CardViewType, cv.getId(), cv); + } + } else if (obj instanceof PlayerView pv) { + if (tracker.getObj(TrackableTypes.PlayerViewType, pv.getId()) == null) { + tracker.putObj(TrackableTypes.PlayerViewType, pv.getId(), pv); + } + } + } + private void replicateProps(final Object[] objs) { for (Object obj: objs) { if (obj instanceof PlayerView pv) { From dc3c95e611b8e0f2fbfb8340f8b189ce4568b07e Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 11 May 2026 06:36:34 +0930 Subject: [PATCH 2/4] Mirror IdRef compression on the client encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set the tracker on the client encoder so client→server CardView args serialize as IdRef when the tracker holds them. Drop the bulk-register of inline-arrived CardViews — tracker miss is now the symmetric ephemeral signal on both ends. Addresses review feedback that the prior commit dropped the protocol-arg IdRef optimization on the client→server leg. With the client encoder running the same presence check the host uses, real CardViews (which reach the client via delta first) round-trip as IdRef; ephemerals (absent from both trackers) serialize as full objects in both directions, matching the host's behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../net/client/GameClientHandler.java | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index a21549fcc81..5a9d666dbac 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -4,6 +4,7 @@ import forge.game.card.CardView; import forge.game.player.PlayerView; import forge.gamemodes.net.CompatibleObjectDecoder; +import forge.gamemodes.net.CompatibleObjectEncoder; import forge.gamemodes.net.GameProtocolHandler; import forge.gui.GuiBase; import forge.util.IHasForgeLog; @@ -74,12 +75,14 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod if (args.length > 0 && args[0] instanceof GameView gameView) { if (this.tracker == null) { this.tracker = new Tracker(); - // Set tracker on decoder for IdRef resolution in server messages. - // The client encoder does NOT get a tracker — it uses simple - // IdRef replacement without stale detection. Stale detection - // on the client would create StaleCardRef markers for cards - // updated by delta sync, causing the server to create detached - // CardViews that don't match real game objects. + // Encoder uses the tracker to emit IdRef for client→server + // CardView args (presence check only — stale detection is + // server-only). Ephemerals absent from the tracker + // serialize as full objects in both directions. + CompatibleObjectEncoder encoder = ctx.pipeline().get(CompatibleObjectEncoder.class); + if (encoder != null) { + encoder.setTracker(this.tracker); + } CompatibleObjectDecoder decoder = ctx.pipeline().get(CompatibleObjectDecoder.class); if (decoder != null) { decoder.setTracker(this.tracker); @@ -126,9 +129,11 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod /** * This method is used to recursively update the tracker - * references on all objects and their props, and register them in the - * id lookup so inline-serialized CardViews are findable by IdRef - * in subsequent messages. + * references on all objects and their props. + * + *

Inline-serialized CardViews are intentionally NOT registered in the + * tracker's id lookup: a tracker miss is the symmetric signal that a + * CardView is ephemeral, mirroring the host's encoder check. * * @param objs */ @@ -137,7 +142,6 @@ private void updateTrackers(final Object[] objs) { if (obj instanceof TrackableObject trackableObject) { if (trackableObject.getTracker() == null) { trackableObject.setTracker(this.tracker); - registerInTracker(trackableObject); // walk the props EnumMap props = trackableObject.getProps(); if (props != null) { @@ -156,18 +160,6 @@ private void updateTrackers(final Object[] objs) { } } - private void registerInTracker(final TrackableObject obj) { - if (obj instanceof CardView cv) { - if (tracker.getObj(TrackableTypes.CardViewType, cv.getId()) == null) { - tracker.putObj(TrackableTypes.CardViewType, cv.getId(), cv); - } - } else if (obj instanceof PlayerView pv) { - if (tracker.getObj(TrackableTypes.PlayerViewType, pv.getId()) == null) { - tracker.putObj(TrackableTypes.PlayerViewType, pv.getId(), pv); - } - } - } - private void replicateProps(final Object[] objs) { for (Object obj: objs) { if (obj instanceof PlayerView pv) { From ecc5deacb1aec28dcdbfb61736f50d28d0d1cfde Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Mon, 11 May 2026 17:31:19 +0200 Subject: [PATCH 3/4] Clean up --- .../main/java/forge/card/CardTypeView.java | 10 +++--- .../main/java/forge/game/card/CardView.java | 30 ++++++---------- .../java/forge/game/player/PlayerView.java | 20 +---------- .../src/forge/toolbox/FChoiceList.java | 14 ++++---- .../src/forge/util/CardRendererUtils.java | 1 - .../gamemodes/net/TrackableSerializer.java | 35 +++++++------------ .../net/client/GameClientHandler.java | 7 ++-- 7 files changed, 38 insertions(+), 79 deletions(-) diff --git a/forge-core/src/main/java/forge/card/CardTypeView.java b/forge-core/src/main/java/forge/card/CardTypeView.java index 5a997ea842c..5fc907f15ac 100644 --- a/forge-core/src/main/java/forge/card/CardTypeView.java +++ b/forge-core/src/main/java/forge/card/CardTypeView.java @@ -28,11 +28,11 @@ public interface CardTypeView extends Serializable { boolean hasABasicLandType(); boolean hasANonBasicLandType(); - public boolean sharesCreaturetypeWith(final CardTypeView ctOther); - public boolean sharesLandTypeWith(final CardTypeView ctOther); - public boolean sharesPermanentTypeWith(final CardTypeView ctOther); - public boolean sharesCardTypeWith(final CardTypeView ctOther); - public boolean sharesAllCardTypesWith(final CardTypeView ctOther); + boolean sharesCreaturetypeWith(final CardTypeView ctOther); + boolean sharesLandTypeWith(final CardTypeView ctOther); + boolean sharesPermanentTypeWith(final CardTypeView ctOther); + boolean sharesCardTypeWith(final CardTypeView ctOther); + boolean sharesAllCardTypesWith(final CardTypeView ctOther); boolean isPermanent(); boolean isCreature(); diff --git a/forge-game/src/main/java/forge/game/card/CardView.java b/forge-game/src/main/java/forge/game/card/CardView.java index 361b8463a7a..9108e95e758 100644 --- a/forge-game/src/main/java/forge/game/card/CardView.java +++ b/forge-game/src/main/java/forge/game/card/CardView.java @@ -453,12 +453,14 @@ void updateChosenColors(Card c) { set(TrackableProperty.ChosenColors, c.getChosenColors()); flagAsChanged(TrackableProperty.ChosenColors); } + public boolean hasPaperFoil() { return get(TrackableProperty.PaperFoil); } void updatePaperFoil(boolean v) { set(TrackableProperty.PaperFoil, v); } + public ColorSet getMarkedColors() { return get(TrackableProperty.MarkedColors); } @@ -527,15 +529,6 @@ void updateIntensity(Card c) { set(TrackableProperty.Intensity, c.getIntensity(true)); } - public boolean wasDestroyed() { - if (get(TrackableProperty.WasDestroyed) == null) - return false; - return get(TrackableProperty.WasDestroyed); - } - void updateWasDestroyed(boolean value) { - set(TrackableProperty.WasDestroyed, value); - } - public int getClassLevel() { return get(TrackableProperty.ClassLevel); } @@ -600,7 +593,7 @@ void updateRemembered(Card c) { sb.append("\r\nRemembered: \r\n"); for (final Object o : c.getRemembered()) { if (o != null) { - sb.append(o.toString()); + sb.append(o); sb.append("\r\n"); } } @@ -727,7 +720,6 @@ public boolean canBeShownTo(final PlayerView viewer) { public boolean canFaceDownBeShownToAny(final Iterable viewers) { if (viewers == null || Iterables.isEmpty(viewers)) { return true; } - return IterableUtil.any(viewers, this::canFaceDownBeShownTo); } @@ -1011,25 +1003,26 @@ void updateBackSide(String stateName, boolean hasBackSide) { set(TrackableProperty.HasBackSide, hasBackSide); set(TrackableProperty.BackSideName, stateName); } + + public boolean wasDestroyed() { + return get(TrackableProperty.WasDestroyed); + } + void updateWasDestroyed(boolean value) { + set(TrackableProperty.WasDestroyed, value); + } public boolean needsUntapAnimation() { - if (get(TrackableProperty.NeedsUntapAnimation) == null) - return false; return get(TrackableProperty.NeedsUntapAnimation); } public void updateNeedsUntapAnimation(boolean value) { set(TrackableProperty.NeedsUntapAnimation, value); } public boolean needsTapAnimation() { - if (get(TrackableProperty.NeedsTapAnimation) == null) - return false; return get(TrackableProperty.NeedsTapAnimation); } public void updateNeedsTapAnimation(boolean value) { set(TrackableProperty.NeedsTapAnimation, value); } public boolean needsTransformAnimation() { - if (get(TrackableProperty.NeedsTransformAnimation) == null) - return false; return get(TrackableProperty.NeedsTransformAnimation); } public void updateNeedsTransformAnimation(boolean value) { @@ -1070,8 +1063,6 @@ void updateState(Card c) { updateBackSide(c.getAlternateState().getName(), c.isDoubleFaced()); final Card cloner = c.getCloner(); - - //CardStateView cloner = CardView.getState(c, CardStateName.Cloner); set(TrackableProperty.Cloner, cloner == null ? null : cloner.getName() + " (" + cloner.getId() + ")"); CardCollection mergedCollection = new CardCollection(); @@ -1194,7 +1185,6 @@ void updateBlockAdditional(Card c) { public boolean isRingBearer() { return get(TrackableProperty.IsRingBearer); } - void updateRingBearer(Card c) { set(TrackableProperty.IsRingBearer, c.isRingBearer()); } diff --git a/forge-game/src/main/java/forge/game/player/PlayerView.java b/forge-game/src/main/java/forge/game/player/PlayerView.java index daddba99a62..25224deefee 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerView.java +++ b/forge-game/src/main/java/forge/game/player/PlayerView.java @@ -197,17 +197,13 @@ void updateCounters(Player p) { public boolean getIsExtraTurn() { return get(TrackableProperty.IsExtraTurn); } - public void setIsExtraTurn(final boolean val) { set(TrackableProperty.IsExtraTurn, val); } public boolean getHasLost() { - if (get(TrackableProperty.HasLost) == null) - return false; return get(TrackableProperty.HasLost); } - public void setHasLost(final boolean val) { set(TrackableProperty.HasLost, val); } @@ -216,8 +212,6 @@ public int getAvatarLifeDifference() { return (int)get(TrackableProperty.AvatarLifeDifference); } public boolean wasAvatarLifeChanged() { - if ((int)get(TrackableProperty.AvatarLifeDifference) == 0) - return false; return (int)get(TrackableProperty.AvatarLifeDifference) != 0; } public void setAvatarLifeDifference(final int val) { @@ -227,7 +221,6 @@ public void setAvatarLifeDifference(final int val) { public int getExtraTurnCount() { return get(TrackableProperty.ExtraTurnCount); } - public void setExtraTurnCount(final int val) { set(TrackableProperty.ExtraTurnCount, val); } @@ -497,19 +490,8 @@ void updateFlashback(Player p) { set(TrackableProperty.Flashback, CardView.getCollection(p.getCardsIn(ZoneType.Flashback))); } - public int getMana(final int manaAtom) { - return getMana((byte) manaAtom); - } public int getMana(final byte color) { - Integer count = null; - try { - count = getMana().get(color); - } - catch (Exception e) { - e.printStackTrace(); - count = null; - } - return count != null ? count : 0; + return getMana().getOrDefault(color, 0); } private Map getMana() { return get(TrackableProperty.Mana); diff --git a/forge-gui-mobile/src/forge/toolbox/FChoiceList.java b/forge-gui-mobile/src/forge/toolbox/FChoiceList.java index 5cf4acd895c..f42235a123f 100644 --- a/forge-gui-mobile/src/forge/toolbox/FChoiceList.java +++ b/forge-gui-mobile/src/forge/toolbox/FChoiceList.java @@ -604,14 +604,12 @@ public void drawValue(Graphics g, T value, FSkinFont font, FSkinColor foreColor, else CardRenderer.drawCard(g, cv, x, y, VStack.CARD_WIDTH, VStack.CARD_HEIGHT, CardStackPosition.Top, false, showAlternate, true, false); } - } else { - if (cv != null) { - boolean showAlternate = CardRendererUtils.canShowAlternate(cv, value.toString()); - if (!cv.isFaceDown()) - CardRenderer.drawCardWithOverlays(g, cv, x, y, VStack.CARD_WIDTH, VStack.CARD_HEIGHT, CardStackPosition.Top, false, showAlternate, true); - else - CardRenderer.drawCard(g, cv, x, y, VStack.CARD_WIDTH, VStack.CARD_HEIGHT, CardStackPosition.Top, false, showAlternate, true, false); - } + } else if (cv != null) { + boolean showAlternate = CardRendererUtils.canShowAlternate(cv, value.toString()); + if (!cv.isFaceDown()) + CardRenderer.drawCardWithOverlays(g, cv, x, y, VStack.CARD_WIDTH, VStack.CARD_HEIGHT, CardStackPosition.Top, false, showAlternate, true); + else + CardRenderer.drawCard(g, cv, x, y, VStack.CARD_WIDTH, VStack.CARD_HEIGHT, CardStackPosition.Top, false, showAlternate, true, false); } } catch (Exception ignored) { //fixme: java.lang.ClassCastException for cards like Subtlety which should be cancelable instead... diff --git a/forge-gui-mobile/src/forge/util/CardRendererUtils.java b/forge-gui-mobile/src/forge/util/CardRendererUtils.java index 40f695165c8..3ce9cec8353 100644 --- a/forge-gui-mobile/src/forge/util/CardRendererUtils.java +++ b/forge-gui-mobile/src/forge/util/CardRendererUtils.java @@ -64,7 +64,6 @@ public static boolean hasAftermath(final CardView card) { return false; } - public static boolean isPreferenceEnabled(final ForgePreferences.FPref preferenceName) { return FModel.getPreferences().getPrefBoolean(preferenceName); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 907ccae7845..67bb01e562a 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -63,10 +63,9 @@ static TrackableType trackableTypeFor(byte typeTag) { * ({@code eventMode = true}). When the tracker holds a different object * for the CardView's id (zone-change copy), {@code preserveSnapshot} is * set so the receiver decodes a detached CardView from the carried name - * and image key. When {@code tracker} is null, the snapshot check is - * skipped (used by the client encoder, which has no game-state awareness). - * - *

In non-event mode, CardViews missing from the tracker pass through + * and image key. + *

+ * In non-event mode, CardViews missing from the tracker pass through * unchanged so Java serializes the full object inline (covers ephemeral * choice copies that never enter a tracked zone). */ @@ -80,23 +79,17 @@ static Object replace(Object obj, Tracker tracker, boolean eventMode) { } if (!eventMode) { - if (tracker != null) { - TrackableType type = trackableTypeFor(tag); - if (type != null && tracker.getObj(type, trackable.getId()) != null) { - return new IdRef(tag, trackable.getId()); - } + if (tracker != null && tracker.getObj(trackableTypeFor(tag), trackable.getId()) != null) { + return new IdRef(tag, trackable.getId()); } - return obj; // ephemeral or tracker-less encoder — serialize the full object + return obj; } boolean preserveSnapshot = false; if (tracker != null) { - TrackableType type = trackableTypeFor(tag); - if (type != null) { - Object tracked = tracker.getObj(type, trackable.getId()); - if (tracked != null && tracked != trackable) { - preserveSnapshot = true; - } + Object tracked = tracker.getObj(trackableTypeFor(tag), trackable.getId()); + if (tracked != null && tracked != trackable) { + preserveSnapshot = true; } } CardView cv = (CardView) trackable; @@ -129,13 +122,11 @@ static Object resolve(Object obj, Tracker tracker) { } if (obj instanceof IdRef ref) { TrackableType type = trackableTypeFor(ref.typeTag()); - if (type != null) { - Object resolved = tracker.getObj(type, ref.id()); - if (resolved == null) { - netLog.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag(), ref.id()); - } - return resolved; + Object resolved = tracker.getObj(type, ref.id()); + if (resolved == null) { + netLog.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag(), ref.id()); } + return resolved; } return obj; } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index 5a9d666dbac..b8d504f138e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -75,10 +75,9 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod if (args.length > 0 && args[0] instanceof GameView gameView) { if (this.tracker == null) { this.tracker = new Tracker(); - // Encoder uses the tracker to emit IdRef for client→server - // CardView args (presence check only — stale detection is - // server-only). Ephemerals absent from the tracker - // serialize as full objects in both directions. + // Encoder uses the tracker to emit IdRef for client→server CardView args + // (presence check only — stale detection is server-only). + // Ephemerals absent from the tracker serialize as full objects in both directions. CompatibleObjectEncoder encoder = ctx.pipeline().get(CompatibleObjectEncoder.class); if (encoder != null) { encoder.setTracker(this.tracker); From 77cb486466496fe9d1ca1587985b2f84b00f26b5 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Mon, 11 May 2026 17:34:01 +0200 Subject: [PATCH 4/4] Clean up --- .../src/main/java/forge/gamemodes/net/TrackableSerializer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 67bb01e562a..3a817c7e165 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -121,8 +121,7 @@ static Object resolve(Object obj, Tracker tracker) { return detached; } if (obj instanceof IdRef ref) { - TrackableType type = trackableTypeFor(ref.typeTag()); - Object resolved = tracker.getObj(type, ref.id()); + Object resolved = tracker.getObj(trackableTypeFor(ref.typeTag()), ref.id()); if (resolved == null) { netLog.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag(), ref.id()); }