GameAction: cleanup Static giving Abilities to the Stack (#8587)

* Add test that fails on master engine (before KeywordCollection sanity fix)
This commit is contained in:
Hans Mackowiak
2025-10-18 14:24:29 +02:00
committed by GitHub
parent d94fe1e958
commit a99c899eec
5 changed files with 58 additions and 35 deletions

View File

@@ -5,6 +5,7 @@ import forge.ai.ComputerUtil;
import forge.ai.PlayerControllerAi; import forge.ai.PlayerControllerAi;
import forge.ai.simulation.GameStateEvaluator.Score; import forge.ai.simulation.GameStateEvaluator.Score;
import forge.game.Game; import forge.game.Game;
import forge.game.GameActionUtil;
import forge.game.GameObject; import forge.game.GameObject;
import forge.game.card.Card; import forge.game.card.Card;
import forge.game.phase.PhaseType; import forge.game.phase.PhaseType;
@@ -131,6 +132,19 @@ public class GameSimulator {
Card hostCard = (Card) copier.find(origHostCard); Card hostCard = (Card) copier.find(origHostCard);
String desc = sa.getDescription(); String desc = sa.getDescription();
FCollectionView<SpellAbility> candidates = hostCard.getSpellAbilities(); FCollectionView<SpellAbility> candidates = hostCard.getSpellAbilities();
SpellAbility result = saMatcher(candidates, desc);
for (SpellAbility cSa : candidates) {
if (result != null) {
break;
}
result = saMatcher(GameActionUtil.getAlternativeCosts(cSa, aiPlayer, true), desc);
}
return result;
}
private SpellAbility saMatcher(Iterable<SpellAbility> candidates, String desc) {
// first pass for accuracy (spells with alternative costs) // first pass for accuracy (spells with alternative costs)
for (SpellAbility cSa : candidates) { for (SpellAbility cSa : candidates) {
if (desc.equals(cSa.getDescription())) { if (desc.equals(cSa.getDescription())) {

View File

@@ -223,6 +223,7 @@ public class GameAction {
} }
if (cause != null && cause.isSpell() && c.equals(cause.getHostCard())) { if (cause != null && cause.isSpell() && c.equals(cause.getHostCard())) {
copied.setCastFrom(zoneFrom);
copied.setCastSA(cause); copied.setCastSA(cause);
copied.setSplitStateToPlayAbility(cause); copied.setSplitStateToPlayAbility(cause);
@@ -232,6 +233,13 @@ public class GameAction {
KeywordInterface kw = cause.getKeyword(); KeywordInterface kw = cause.getKeyword();
if (kw != null) { if (kw != null) {
copied.addKeywordForStaticAbility(kw); copied.addKeywordForStaticAbility(kw);
// CR 400.7g If an effect grants a nonland card an ability that allows it to be cast,
// that ability will continue to apply to the new object that card became after it moved to the stack as a result of being cast this way.
if (!cause.isIntrinsic()) {
kw.setHostCard(copied);
copied.addChangedCardKeywordsInternal(ImmutableList.of(kw), null, false, copied.getGameTimestamp(), kw.getStatic(), false);
}
} }
} }
} else { } else {
@@ -300,7 +308,6 @@ public class GameAction {
// Temporary disable commander replacement effect // Temporary disable commander replacement effect
// 903.9a // 903.9a
if (fromBattlefield && !toBattlefield && c.isCommander() && c.hasMergedCard()) { if (fromBattlefield && !toBattlefield && c.isCommander() && c.hasMergedCard()) {
// Disable the commander replacement effect
c.getOwner().setCommanderReplacementSuppressed(true); c.getOwner().setCommanderReplacementSuppressed(true);
} }
@@ -445,14 +452,8 @@ public class GameAction {
} }
if (zoneFrom.is(ZoneType.Stack) && toBattlefield) { if (zoneFrom.is(ZoneType.Stack) && toBattlefield) {
// 400.7a Effects from static abilities that give a permanent spell on the stack an ability // CR 400.7b Effects from static abilities that grant an ability to a permanent spell that functions on the battlefield
// that allows it to be cast for an alternative cost continue to apply to the permanent that spell becomes. // continue to apply to the permanent that spell becomes
if (c.getCastSA() != null && !c.getCastSA().isIntrinsic() && c.getKeywords().contains(c.getCastSA().getKeyword())) {
KeywordInterface ki = c.getCastSA().getKeyword();
ki.setHostCard(copied);
copied.addChangedCardKeywordsInternal(ImmutableList.of(ki), null, false, copied.getGameTimestamp(), null, true);
}
// TODO hot fix for non-intrinsic offspring
Multimap<StaticAbility, KeywordInterface> addKw = MultimapBuilder.hashKeys().arrayListValues().build(); Multimap<StaticAbility, KeywordInterface> addKw = MultimapBuilder.hashKeys().arrayListValues().build();
for (KeywordInterface kw : c.getKeywords(Keyword.OFFSPRING)) { for (KeywordInterface kw : c.getKeywords(Keyword.OFFSPRING)) {
if (!kw.isIntrinsic()) { if (!kw.isIntrinsic()) {
@@ -465,7 +466,7 @@ public class GameAction {
} }
} }
// 607.2q linked ability can find cards exiled as cost while it was a spell // CR 607.2q linked ability can find cards exiled as cost while it was a spell
copied.addExiledCards(c.getExiledCards()); copied.addExiledCards(c.getExiledCards());
} }
@@ -486,7 +487,7 @@ public class GameAction {
if (card.isRealCommander()) { if (card.isRealCommander()) {
card.setMoveToCommandZone(true); card.setMoveToCommandZone(true);
} }
// 727.3e & 903.9a // CR 727.3e & 903.9a
if (wasToken && !card.isRealToken() || card.isRealCommander()) { if (wasToken && !card.isRealToken() || card.isRealCommander()) {
Map<AbilityKey, Object> repParams = AbilityKey.mapFromAffected(card); Map<AbilityKey, Object> repParams = AbilityKey.mapFromAffected(card);
repParams.put(AbilityKey.CardLKI, card); repParams.put(AbilityKey.CardLKI, card);
@@ -561,13 +562,6 @@ public class GameAction {
// update static abilities after etb counters have been placed // update static abilities after etb counters have been placed
checkStaticAbilities(); checkStaticAbilities();
// 400.7g try adding keyword back into card if it doesn't already have it
if (zoneTo.is(ZoneType.Stack) && cause != null && cause.isSpell() && !cause.isIntrinsic() && c.equals(cause.getHostCard())) {
if (cause.getKeyword() != null && !copied.getKeywords().contains(cause.getKeyword())) {
copied.addChangedCardKeywordsInternal(ImmutableList.of(cause.getKeyword()), null, false, game.getNextTimestamp(), null, true);
}
}
// CR 603.6b // CR 603.6b
if (toBattlefield) { if (toBattlefield) {
zoneTo.saveLKI(copied, lastKnownInfo); zoneTo.saveLKI(copied, lastKnownInfo);
@@ -827,19 +821,6 @@ public class GameAction {
} }
} }
if (zoneTo.is(ZoneType.Stack)) {
c.setCastFrom(zoneFrom);
if (cause != null && cause.isSpell() && c.equals(cause.getHostCard())) {
c.setCastSA(cause);
} else {
c.setCastSA(null);
}
} else if (zoneFrom == null || !(zoneFrom.is(ZoneType.Stack) &&
(zoneTo.is(ZoneType.Battlefield) || zoneTo.is(ZoneType.Merged)))) {
c.setCastFrom(null);
c.setCastSA(null);
}
if (c.isRealCommander()) { if (c.isRealCommander()) {
c.setMoveToCommandZone(true); c.setMoveToCommandZone(true);
} }

View File

@@ -2582,7 +2582,6 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr
sbLong.append(" ").append(ManaCostParser.parse(k[1])); sbLong.append(" ").append(ManaCostParser.parse(k[1]));
sbLong.append(" (").append(inst.getReminderText()).append(")"); sbLong.append(" (").append(inst.getReminderText()).append(")");
sbLong.append("\r\n"); sbLong.append("\r\n");
} else if (inst.getKeyword().equals(Keyword.COMPANION)) { } else if (inst.getKeyword().equals(Keyword.COMPANION)) {
sbLong.append("Companion — "); sbLong.append("Companion — ");
sbLong.append(((Companion)inst).getDescription()); sbLong.append(((Companion)inst).getDescription());

View File

@@ -151,19 +151,19 @@ public class AITest {
return c; return c;
} }
void playUntilStackClear(Game game) { protected void playUntilStackClear(Game game) {
do { do {
game.getPhaseHandler().mainLoopStep(); game.getPhaseHandler().mainLoopStep();
} while (!game.isGameOver() && !game.getStack().isEmpty()); } while (!game.isGameOver() && !game.getStack().isEmpty());
} }
void playUntilPhase(Game game, PhaseType phase) { protected void playUntilPhase(Game game, PhaseType phase) {
do { do {
game.getPhaseHandler().mainLoopStep(); game.getPhaseHandler().mainLoopStep();
} while (!game.isGameOver() && !game.getPhaseHandler().is(phase)); } while (!game.isGameOver() && !game.getPhaseHandler().is(phase));
} }
void playUntilNextTurn(Game game) { protected void playUntilNextTurn(Game game) {
Player current = game.getPhaseHandler().getPlayerTurn(); Player current = game.getPhaseHandler().getPlayerTurn();
do { do {
game.getPhaseHandler().mainLoopStep(); game.getPhaseHandler().mainLoopStep();

View File

@@ -2610,6 +2610,35 @@ public class GameSimulationTest extends SimulationTest {
AssertJUnit.assertTrue(nonBasicForest.getType().hasSubtype("Mountain")); AssertJUnit.assertTrue(nonBasicForest.getType().hasSubtype("Mountain"));
} }
@Test
public void testHenzie() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(0);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
addCard("Henzie \"Toolbox\" Torre", p);
addCardToZone("Wastes", p, ZoneType.Library);
addCards("Plains", 5, p);
Card spell = addCardToZone("Serra Angel", p, ZoneType.Hand);
game.getAction().checkStaticAbilities();
List<SpellAbility> sas = spell.getAllPossibleAbilities(p, true);
SpellAbility blitz = sas.get(1);
GameSimulator sim = createSimulator(game, p);
game = sim.getSimulatedGameState();
sim.simulateSpellAbility(blitz);
spell = findCardWithName(game, "Serra Angel");
AssertJUnit.assertEquals(1, spell.getAmountOfKeyword(Keyword.BLITZ));
AssertJUnit.assertTrue(spell.hasKeyword(Keyword.HASTE));
playUntilNextTurn(game);
AssertJUnit.assertEquals(1, game.getPlayers().get(0).getCardsIn(ZoneType.Hand).size());
AssertJUnit.assertTrue(spell.isInZone(ZoneType.Graveyard));
}
/** /**
* Test for "Volo's Journal" usage by the AI. This test checks if the AI correctly * Test for "Volo's Journal" usage by the AI. This test checks if the AI correctly
* adds the correct types to the "Volo's Journal" when casting the spells in order * adds the correct types to the "Volo's Journal" when casting the spells in order