diff --git a/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java b/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java index e2e828d4c366..1dbb9bdc5147 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java @@ -12,6 +12,7 @@ import forge.game.card.Card; import forge.game.card.CardCloneStates; import forge.game.card.CardCopyService; +import forge.game.card.CardFactory; import forge.game.card.CounterType; import forge.game.card.token.TokenInfo; import forge.game.combat.Combat; @@ -97,7 +98,9 @@ public Game makeCopy(PhaseType advanceToPhase, Player aiPlayer) { newPlayer.setLandsPlayedThisTurn(origPlayer.getLandsPlayedThisTurn()); newPlayer.setCounters(HashMultiset.create(origPlayer.getCounters())); newPlayer.setSpeed(origPlayer.getSpeed()); - newPlayer.setBlessing(origPlayer.hasBlessing(), null); + // Blessing state travels with the copied command-zone effect card + // (wired via copyEffectCardsToSnapshot below); calling setBlessing + // here would create a second effect card the zone copy duplicates. newPlayer.setDescended(origPlayer.getDescended()); newPlayer.setLibrarySearched(origPlayer.getLibrarySearched()); newPlayer.setSpellsCastLastTurn(origPlayer.getSpellsCastLastTurn()); @@ -127,6 +130,7 @@ public Game makeCopy(PhaseType advanceToPhase, Player aiPlayer) { for (Player origPlayer : playerMap.keySet()) { Player newPlayer = playerMap.get(origPlayer); origPlayer.copyCommandersToSnapshot(newPlayer, gameObjectMap::map); + origPlayer.copyEffectCardsToSnapshot(newPlayer, gameObjectMap::map); ((PlayerZoneBattlefield) newPlayer.getZone(ZoneType.Battlefield)).setTriggers(true); } newGame.getTriggerHandler().clearSuppression(TriggerType.ChangesZone); @@ -214,6 +218,11 @@ private RegisteredPlayer clonePlayer(RegisteredPlayer p) { } private void copyGameState(Game newGame, Player aiPlayer) { + // Copied cards keep their original ids (id order is AI-visible via + // Card.compareTo and id-keyed collections; renumbering makes forked + // games deterministically diverge from the mainline). Sync the fresh-id + // counters first so ids created during or after the copy cannot collide. + newGame.dangerouslySyncCardIdCounters(origGame); newGame.EXPERIMENTAL_RESTORE_SNAPSHOT = origGame.EXPERIMENTAL_RESTORE_SNAPSHOT; newGame.AI_TIMEOUT = origGame.AI_TIMEOUT; newGame.AI_CAN_USE_TIMEOUT = origGame.AI_CAN_USE_TIMEOUT; @@ -287,7 +296,7 @@ private void copyGameState(Game newGame, Player aiPlayer) { private static final boolean USE_FROM_PAPER_CARD = true; private Card createCardCopy(Game newGame, Player newOwner, Card c, Player aiPlayer) { if (c.isToken() && !c.isImmutable()) { - Card result = new TokenInfo(c).makeOneToken(newOwner); + Card result = new TokenInfo(c).makeOneToken(newOwner, c.getId()); new CardCopyService(c).copyCopiableCharacteristics(result, null, null); return result; } @@ -295,10 +304,10 @@ private Card createCardCopy(Game newGame, Player newOwner, Card c, Player aiPlay Card newCard; if (PRUNE_HIDDEN_INFO && !c.getView().canBeShownTo(aiPlayer.getView())) { // TODO also check REVEALED_CARDS memory - newCard = new Card(newGame.nextCardId(), hidden_info_card, newGame); + newCard = new Card(c.getId(), hidden_info_card, newGame); newCard.setOwner(newOwner); } else { - newCard = Card.fromPaperCard(c.getPaperCard(), newOwner); + newCard = CardFactory.getCard(c.getPaperCard(), newOwner, c.getId(), newGame); } newCard.setCommander(c.isCommander()); return newCard; @@ -310,9 +319,10 @@ private Card createCardCopy(Game newGame, Player newOwner, Card c, Player aiPlay // be needed. Once the below code accurately copies the card, remove the USE_FROM_PAPER_CARD code path. Card newCard; if (c instanceof DetachedCardEffect) - newCard = new DetachedCardEffect((DetachedCardEffect) c, newGame, true); + newCard = new DetachedCardEffect((DetachedCardEffect) c, newGame, false); else - newCard = new Card(newGame.nextCardId(), c.getPaperCard(), newGame); + newCard = new Card(c.getId(), c.getPaperCard(), newGame); + newCard.setGamePieceType(c.getGamePieceType()); newCard.setOwner(newOwner); newCard.setName(c.getName()); newCard.setCommander(c.isCommander()); @@ -435,6 +445,19 @@ private void addCard(Game newGame, ZoneType zone, Card c, Player aiPlayer) { newCard.copyChangedSVarsFrom(c); } + if (zone == ZoneType.Exile && c.isFaceDown()) { + // Face-down exile state (foretell, "exile face down" effects) must + // survive the copy: cards rebuilt from their paper card default to + // face up, leaking hidden information into the copied game. + newCard.turnFaceDownNoUpdate(); + if (c.isForetold()) { + newCard.setForetold(true); + if (c.isForetoldCostByEffect()) { + newCard.setForetoldCostByEffect(true); + } + } + } + if (zone == ZoneType.Stack) { newGame.getStackZone().add(newCard); } else { diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java index 3ed6fa026d9d..e3fe0c5090bd 100644 --- a/forge-game/src/main/java/forge/game/Game.java +++ b/forge-game/src/main/java/forge/game/Game.java @@ -550,6 +550,16 @@ public void dangerouslySetTimestamp(long timestamp) { this.timestamp = timestamp; } + /** + * Snapshot support: aligns this game's fresh-card-id counters with the + * source game's, so a copy that preserves original card ids can never + * collide with ids handed out for cards created after the copy. + */ + public void dangerouslySyncCardIdCounters(Game from) { + this.cardIdCounter = from.cardIdCounter; + this.hiddenCardIdCounter = from.hiddenCardIdCounter; + } + public final GameOutcome getOutcome() { return outcome; } diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index 6e36a1fe6511..37295a82ea3b 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -2766,6 +2766,31 @@ public void copyCommandersToSnapshot(Player toPlayer, Function mappe } } + /** + * Wires this player's field-managed effect cards (keyword, monarch, + * initiative, blessing, contraption sprocket, radiation, speed) to their + * already-copied counterparts on a snapshot player, the same way + * copyCommandersToSnapshot wires commanderEffect. Without this, the + * snapshot's lazy getters re-create the effect card on next use while the + * copied original sits orphaned in the command zone (visible as duplicate + * "Keyword Effects" cards that compound with each copy generation). + */ + public void copyEffectCardsToSnapshot(Player toPlayer, Function mapper) { + toPlayer.keywordEffect = mapEffectCard(keywordEffect, mapper); + toPlayer.monarchEffect = mapEffectCard(monarchEffect, mapper); + toPlayer.initiativeEffect = mapEffectCard(initiativeEffect, mapper); + toPlayer.blessingEffect = mapEffectCard(blessingEffect, mapper); + toPlayer.contraptionSprocketEffect = mapEffectCard(contraptionSprocketEffect, mapper); + toPlayer.radiationEffect = mapEffectCard(radiationEffect, mapper); + toPlayer.speedEffect = mapEffectCard(speedEffect, mapper); + } + + private static Card mapEffectCard(Card effect, Function mapper) { + // An effect card in no zone was not part of the copy; leave the + // snapshot's field unset so its lazy creation path stays consistent. + return effect == null || effect.getZone() == null ? null : mapper.apply(effect); + } + public void addCommander(Card commander) { assert(this.equals(commander.getOwner())); //Making someone else's card your commander isn't currently supported. if(this.commanders.contains(commander)) diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulationTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulationTest.java index f93317971b0a..f7f0f47e6470 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulationTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulationTest.java @@ -17,8 +17,10 @@ import org.testng.AssertJUnit; import org.testng.annotations.Test; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -2690,6 +2692,105 @@ public void testVoloJournal() { } } + @Test + public void testGameCopyPreservesFaceDownExileState() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + + // A foretold card sits in exile face down; a copy rebuilt from the + // paper card would come back face up, leaking hidden information. + Card foretold = addCardToZone("Behold the Multiverse", p, ZoneType.Exile); + foretold.turnFaceDownNoUpdate(); + foretold.setForetold(true); + + GameCopier copier = new GameCopier(game); + copier.makeCopy(); + + Card foretoldCopy = (Card) copier.find(foretold); + AssertJUnit.assertNotNull(foretoldCopy); + AssertJUnit.assertTrue(foretoldCopy.isFaceDown()); + AssertJUnit.assertTrue(foretoldCopy.isForetold()); + } + + @Test + public void testGameCopyWiresPlayerEffectCards() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + + p.getKeywordCard(); + p.setBlessing(true, null); + p.createMonarchEffect(null); + AssertJUnit.assertEquals(1, countCardsWithName(game, "Keyword Effects", ZoneType.Command)); + AssertJUnit.assertEquals(1, countCardsWithName(game, "City's Blessing", ZoneType.Command)); + AssertJUnit.assertEquals(1, countCardsWithName(game, "The Monarch", ZoneType.Command)); + + GameCopier copier = new GameCopier(game); + Game copy = copier.makeCopy(); + Player copyP = copy.getPlayer(p.getId()); + + // The copied player's effect-card fields must point at the copies that + // arrived with the command zone: the lazy getter must return the + // existing card instead of creating a duplicate... + copyP.getKeywordCard(); + AssertJUnit.assertEquals(1, countCardsWithName(copy, "Keyword Effects", ZoneType.Command)); + + // ...the blessing must survive without a second effect card... + AssertJUnit.assertTrue(copyP.hasBlessing()); + AssertJUnit.assertEquals(1, countCardsWithName(copy, "City's Blessing", ZoneType.Command)); + + // ...and removal must find the copied card rather than orphan it. + copyP.removeMonarchEffect(); + AssertJUnit.assertEquals(0, countCardsWithName(copy, "The Monarch", ZoneType.Command)); + } + + @Test + public void testGameCopyPreservesCardIds() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + Player opp = game.getPlayers().get(0); + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + + // Cards across zones and copy paths: battlefield, token, hand, + // graveyard, library. Ids are burned between adds (as happens in real + // games when tokens and effect cards come and go) so that the id + // sequence does not coincide with zone-traversal order — a copier that + // renumbers in traversal order then visibly compacts the ids. + addCards("Plains", 2, p); + game.nextCardId(); + addCard("Runeclaw Bear", p); + game.nextCardId(); + game.nextCardId(); + addToken("c_a_treasure_sac", p); + addCardToZone("Island", opp, ZoneType.Hand); + game.nextCardId(); + addCardToZone("Shock", opp, ZoneType.Graveyard); + game.nextCardId(); + addCardToZone("Forest", p, ZoneType.Library); + + GameCopier copier = new GameCopier(game); + Game copy = copier.makeCopy(); + + // Card ids are visible to the AI (Card.compareTo, id-keyed + // collections), so copies must keep them for simulations on the copy + // to play out like the original game. + Set preservedIds = new HashSet<>(); + for (ZoneType zone : new ZoneType[] { ZoneType.Battlefield, ZoneType.Hand, + ZoneType.Graveyard, ZoneType.Library, ZoneType.Command }) { + for (Card c : game.getCardsIn(zone)) { + Card cCopy = (Card) copier.find(c); + AssertJUnit.assertNotNull("no copy mapped for " + c, cCopy); + AssertJUnit.assertEquals("copy of " + c + " must keep its id", c.getId(), cCopy.getId()); + preservedIds.add(cCopy.getId()); + } + } + + // Ids handed out in the copy after the fact must not collide with + // the preserved ones. + AssertJUnit.assertFalse(preservedIds.contains(copy.nextCardId())); + } + /** * Helper method to check if all words in the given list are present in the iterable and unique. *