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.simulation.GameStateEvaluator.Score;
import forge.game.Game;
import forge.game.GameActionUtil;
import forge.game.GameObject;
import forge.game.card.Card;
import forge.game.phase.PhaseType;
@@ -131,6 +132,19 @@ public class GameSimulator {
Card hostCard = (Card) copier.find(origHostCard);
String desc = sa.getDescription();
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)
for (SpellAbility cSa : candidates) {
if (desc.equals(cSa.getDescription())) {

View File

@@ -223,6 +223,7 @@ public class GameAction {
}
if (cause != null && cause.isSpell() && c.equals(cause.getHostCard())) {
copied.setCastFrom(zoneFrom);
copied.setCastSA(cause);
copied.setSplitStateToPlayAbility(cause);
@@ -232,6 +233,13 @@ public class GameAction {
KeywordInterface kw = cause.getKeyword();
if (kw != null) {
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 {
@@ -300,7 +308,6 @@ public class GameAction {
// Temporary disable commander replacement effect
// 903.9a
if (fromBattlefield && !toBattlefield && c.isCommander() && c.hasMergedCard()) {
// Disable the commander replacement effect
c.getOwner().setCommanderReplacementSuppressed(true);
}
@@ -445,14 +452,8 @@ public class GameAction {
}
if (zoneFrom.is(ZoneType.Stack) && toBattlefield) {
// 400.7a Effects from static abilities that give a permanent spell on the stack an ability
// that allows it to be cast for an alternative cost 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
// CR 400.7b Effects from static abilities that grant an ability to a permanent spell that functions on the battlefield
// continue to apply to the permanent that spell becomes
Multimap<StaticAbility, KeywordInterface> addKw = MultimapBuilder.hashKeys().arrayListValues().build();
for (KeywordInterface kw : c.getKeywords(Keyword.OFFSPRING)) {
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());
}
@@ -486,7 +487,7 @@ public class GameAction {
if (card.isRealCommander()) {
card.setMoveToCommandZone(true);
}
// 727.3e & 903.9a
// CR 727.3e & 903.9a
if (wasToken && !card.isRealToken() || card.isRealCommander()) {
Map<AbilityKey, Object> repParams = AbilityKey.mapFromAffected(card);
repParams.put(AbilityKey.CardLKI, card);
@@ -561,13 +562,6 @@ public class GameAction {
// update static abilities after etb counters have been placed
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
if (toBattlefield) {
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()) {
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(inst.getReminderText()).append(")");
sbLong.append("\r\n");
} else if (inst.getKeyword().equals(Keyword.COMPANION)) {
sbLong.append("Companion — ");
sbLong.append(((Companion)inst).getDescription());

View File

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

View File

@@ -2610,6 +2610,35 @@ public class GameSimulationTest extends SimulationTest {
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
* adds the correct types to the "Volo's Journal" when casting the spells in order