TMT: Add Sneak (#8915)

* TMT: Add Sneak

* Sneak on Creature
This commit is contained in:
Hans Mackowiak
2025-10-18 09:27:38 +02:00
committed by GitHub
parent a0a3546691
commit 27a638a32b
8 changed files with 88 additions and 34 deletions

View File

@@ -6,10 +6,12 @@ import java.util.Map;
import com.google.common.collect.Lists;
import forge.game.Game;
import forge.game.GameEntity;
import forge.game.ability.AbilityKey;
import forge.game.ability.SpellAbilityEffect;
import forge.game.card.Card;
import forge.game.card.CardZoneTable;
import forge.game.event.GameEventCombatChanged;
import forge.game.spellability.SpellAbility;
public class PermanentEffect extends SpellAbilityEffect {
@@ -28,24 +30,37 @@ public class PermanentEffect extends SpellAbilityEffect {
final Map<AbilityKey, Object> moveParams = AbilityKey.newMap();
final CardZoneTable table = AbilityKey.addCardZoneTableParams(moveParams, sa);
if ((sa.isIntrinsic() || host.wasCast()) && sa.isSneak()) {
host.setTapped(true);
}
final Card c = game.getAction().moveToPlay(host, sa, moveParams);
sa.setHostCard(c);
// CR 608.3g
if (sa.isIntrinsic() || c.wasCast()) {
if (sa.isDash() && c.isInPlay()) {
if ((sa.isIntrinsic() || c.wasCast()) && c.isInPlay()) {
if (sa.isDash()) {
registerDelayedTrigger(sa, "Hand", Lists.newArrayList(c));
// add AI hint
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Dash"), c.getGame().getNextTimestamp(), 0);
}
if (sa.isBlitz() && c.isInPlay()) {
if (sa.isBlitz()) {
registerDelayedTrigger(sa, "Sacrifice", Lists.newArrayList(c));
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Blitz"), c.getGame().getNextTimestamp(), 0);
}
if (sa.isWarp() && c.isInPlay()) {
if (sa.isWarp()) {
registerDelayedTrigger(sa, "Exile", Lists.newArrayList(c));
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Warp"), c.getGame().getNextTimestamp(), 0);
}
if (sa.isSneak() && c.isCreature()) {
final Card returned = sa.getPaidList("Returned", true).getFirst();
final GameEntity defender = game.getCombat().getDefenderByAttacker(returned);
game.getCombat().addAttacker(c, defender);
game.getCombat().getBandOfAttacker(c).setBlocked(false);
game.updateCombatForView();
game.fireEvent(new GameEventCombatChanged());
}
}
table.triggerChangesZoneAll(game, sa);

View File

@@ -3288,19 +3288,9 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr
} else if (keyword.startsWith("Starting intensity")) {
sbAfter.append(TextUtil.fastReplace(keyword, ":", " ")).append("\r\n");
} else if (keyword.startsWith("Escalate") || keyword.startsWith("Buyback")
|| keyword.startsWith("Freerunning") || keyword.startsWith("Prowl")) {
final String[] k = keyword.split(":");
final String manacost = k[1];
final Cost cost = new Cost(manacost, false);
StringBuilder sbCost = new StringBuilder(k[0]);
if (!cost.isOnlyManaCost()) {
sbCost.append("");
} else {
sbCost.append(" ");
}
sbCost.append(cost.toSimpleString());
sbBefore.append(sbCost).append(" (").append(inst.getReminderText()).append(")");
|| keyword.startsWith("Freerunning") || keyword.startsWith("Prowl")
|| keyword.startsWith("Sneak") || keyword.startsWith("Cleave")) {
sbBefore.append(formatKeywordWithCost(inst));
sbBefore.append("\r\n");
} else if (keyword.startsWith("Multikicker")) {
final String[] n = keyword.split(":");
@@ -3329,22 +3319,7 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr
|| keyword.startsWith("Escape") || keyword.startsWith("Foretell:")
|| keyword.startsWith("Disturb") || keyword.startsWith("Overload")
|| keyword.startsWith("Plot") || keyword.startsWith("Mayhem")) {
final String[] k = keyword.split(":");
final Cost mCost;
if (k.length < 2 || "ManaCost".equals(k[1])) {
mCost = new Cost(getManaCost(), false);
} else {
mCost = new Cost(k[1], false);
}
StringBuilder sbCost = new StringBuilder(k[0]);
if (!mCost.isOnlyManaCost()) {
sbCost.append("");
} else {
sbCost.append(" ");
}
sbCost.append(mCost.toSimpleString());
sbAfter.append(sbCost).append(" (").append(inst.getReminderText()).append(")");
sbAfter.append(formatKeywordWithCost(inst));
sbAfter.append("\r\n");
} else if (keyword.equals("Gift")) {
sbBefore.append(keyword);
@@ -3456,6 +3431,26 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr
return sb;
}
private String formatKeywordWithCost(final KeywordInterface inst) {
final String keyword = inst.getOriginal();
final String[] k = keyword.split(":");
final Cost mCost;
if (k.length < 2 || "ManaCost".equals(k[1])) {
mCost = new Cost(getManaCost(), false);
} else {
mCost = new Cost(k[1], false);
}
StringBuilder sbCost = new StringBuilder(k[0]);
if (!mCost.isOnlyManaCost()) {
sbCost.append("");
} else {
sbCost.append(" ");
}
sbCost.append(mCost.toSimpleString());
return sbCost + " (" + inst.getReminderText() + ")";
}
private String formatSpellAbility(final SpellAbility sa) {
final StringBuilder sb = new StringBuilder();
sb.append(sa.toString()).append("\r\n\r\n");

View File

@@ -3621,6 +3621,31 @@ public class CardFactoryUtil {
sa.setSVar("ScavengeX", "Exiled$CardPower");
sa.setIntrinsic(intrinsic);
inst.addSpellAbility(sa);
} else if (keyword.startsWith("Sneak")) {
final String[] k = keyword.split(":");
final String manaCost = k[1];
final Cost webCost = new Cost(manaCost + " Return<1/Creature.attacking+unblocked/unblocked attacker>", false);
final SpellAbility newSA = card.getFirstSpellAbilityWithFallback().copyWithManaCostReplaced(host.getController(), webCost);
if (k.length > 2) {
newSA.getMapParams().put("ValidAfterStack", k[2]);
}
final StringBuilder desc = new StringBuilder();
desc.append("Sneak ").append(ManaCostParser.parse(manaCost)).append(" (");
desc.append(inst.getReminderText());
desc.append(")");
newSA.setDescription(desc.toString());
final StringBuilder sb = new StringBuilder();
sb.append(card.getName()).append(" (Sneak)");
newSA.setStackDescription(sb.toString());
newSA.putParam("Secondary", "True");
newSA.setAlternativeCost(AlternativeCost.Sneak);
newSA.setIntrinsic(intrinsic);
inst.addSpellAbility(newSA);
} else if (keyword.startsWith("Station")) {
String effect = "AB$ PutCounter | Cost$ tapXType<1/Creature.Other> | Defined$ Self " +
"| CounterType$ CHARGE | CounterNum$ StationX | SorcerySpeed$ True " +

View File

@@ -168,10 +168,11 @@ public enum Keyword {
RIOT("Riot", SimpleKeyword.class, false, "This creature enters with your choice of a +1/+1 counter or haste."),
RIPPLE("Ripple", KeywordWithAmount.class, false, "When you cast this spell, you may reveal the top {%d:card} of your library. You may cast any of those cards with the same name as this spell without paying their mana costs. Put the rest on the bottom of your library in any order."),
SADDLE("Saddle", KeywordWithAmount.class, false, "Tap any number of other creatures you control with total power %1$d or more: This Mount becomes saddled until end of turn. Saddle only as a sorcery."),
SCAVENGE("Scavenge", KeywordWithCost.class, false, "%s, Exile this card from your graveyard: Put a number of +1/+1 counters equal to this card's power on target creature. Scavenge only as a sorcery."),
SHADOW("Shadow", SimpleKeyword.class, true, "This creature can block or be blocked by only creatures with shadow."),
SHROUD("Shroud", SimpleKeyword.class, true, "This can't be the target of spells or abilities."),
SKULK("Skulk", SimpleKeyword.class, true, "This creature can't be blocked by creatures with greater power."),
SCAVENGE("Scavenge", KeywordWithCost.class, false, "%s, Exile this card from your graveyard: Put a number of +1/+1 counters equal to this card's power on target creature. Scavenge only as a sorcery."),
SNEAK("Sneak", KeywordWithCost.class, false, "You may cast this spell for %s if you also return an unblocked attacker you control to hand during the declare blockers step."),
SOULBOND("Soulbond", SimpleKeyword.class, true, "You may pair this creature with another unpaired creature when either enters. They remain paired for as long as you control both of them."),
SOULSHIFT("Soulshift", KeywordWithAmount.class, false, "When this creature dies, you may return target Spirit card with mana value %d or less from your graveyard to your hand."),
SPACE_SCULPTOR("Space sculptor", SimpleKeyword.class, true, "CARDNAME divides the battlefield into alpha, beta, and gamma sectors. If a creature isn't assigned to a sector, its controller assigns it to one. Opponents assign first."),

View File

@@ -21,6 +21,7 @@ public enum AlternativeCost {
Overload,
Prowl,
Plotted,
Sneak,
Spectacle,
Surge,
Warp,

View File

@@ -686,6 +686,10 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
return isAlternativeCost(AlternativeCost.Prowl);
}
public final boolean isSneak() {
return isAlternativeCost(AlternativeCost.Sneak);
}
public final boolean isSurged() {
return isAlternativeCost(AlternativeCost.Surge);
}

View File

@@ -337,6 +337,11 @@ public class SpellAbilityRestriction extends SpellAbilityVariables {
return false;
}
}
if (sa.isSneak()) {
if (!game.getPhaseHandler().is(PhaseType.COMBAT_DECLARE_BLOCKERS)) {
return false;
}
}
return true;
}