Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions forge-ai/src/main/java/forge/ai/simulation/GameCopier.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -287,18 +296,18 @@ 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;
}
if (USE_FROM_PAPER_CARD && !c.isImmutable() && c.getPaperCard() != null) {
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;
Expand All @@ -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());
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions forge-game/src/main/java/forge/game/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
25 changes: 25 additions & 0 deletions forge-game/src/main/java/forge/game/player/Player.java
Original file line number Diff line number Diff line change
Expand Up @@ -2766,6 +2766,31 @@ public void copyCommandersToSnapshot(Player toPlayer, Function<Card, Card> 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<Card, Card> 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<Card, Card> 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Integer> 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.
*
Expand Down
Loading