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 com.google.common.collect.Lists;
import forge.game.Game; import forge.game.Game;
import forge.game.GameEntity;
import forge.game.ability.AbilityKey; import forge.game.ability.AbilityKey;
import forge.game.ability.SpellAbilityEffect; import forge.game.ability.SpellAbilityEffect;
import forge.game.card.Card; import forge.game.card.Card;
import forge.game.card.CardZoneTable; import forge.game.card.CardZoneTable;
import forge.game.event.GameEventCombatChanged;
import forge.game.spellability.SpellAbility; import forge.game.spellability.SpellAbility;
public class PermanentEffect extends SpellAbilityEffect { public class PermanentEffect extends SpellAbilityEffect {
@@ -28,24 +30,37 @@ public class PermanentEffect extends SpellAbilityEffect {
final Map<AbilityKey, Object> moveParams = AbilityKey.newMap(); final Map<AbilityKey, Object> moveParams = AbilityKey.newMap();
final CardZoneTable table = AbilityKey.addCardZoneTableParams(moveParams, sa); 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); final Card c = game.getAction().moveToPlay(host, sa, moveParams);
sa.setHostCard(c); sa.setHostCard(c);
// CR 608.3g // CR 608.3g
if (sa.isIntrinsic() || c.wasCast()) { if ((sa.isIntrinsic() || c.wasCast()) && c.isInPlay()) {
if (sa.isDash() && c.isInPlay()) { if (sa.isDash()) {
registerDelayedTrigger(sa, "Hand", Lists.newArrayList(c)); registerDelayedTrigger(sa, "Hand", Lists.newArrayList(c));
// add AI hint // add AI hint
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Dash"), c.getGame().getNextTimestamp(), 0); c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Dash"), c.getGame().getNextTimestamp(), 0);
} }
if (sa.isBlitz() && c.isInPlay()) { if (sa.isBlitz()) {
registerDelayedTrigger(sa, "Sacrifice", Lists.newArrayList(c)); registerDelayedTrigger(sa, "Sacrifice", Lists.newArrayList(c));
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Blitz"), c.getGame().getNextTimestamp(), 0); c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Blitz"), c.getGame().getNextTimestamp(), 0);
} }
if (sa.isWarp() && c.isInPlay()) { if (sa.isWarp()) {
registerDelayedTrigger(sa, "Exile", Lists.newArrayList(c)); registerDelayedTrigger(sa, "Exile", Lists.newArrayList(c));
c.addChangedSVars(Collections.singletonMap("EndOfTurnLeavePlay", "Warp"), c.getGame().getNextTimestamp(), 0); 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); 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")) { } else if (keyword.startsWith("Starting intensity")) {
sbAfter.append(TextUtil.fastReplace(keyword, ":", " ")).append("\r\n"); sbAfter.append(TextUtil.fastReplace(keyword, ":", " ")).append("\r\n");
} else if (keyword.startsWith("Escalate") || keyword.startsWith("Buyback") } else if (keyword.startsWith("Escalate") || keyword.startsWith("Buyback")
|| keyword.startsWith("Freerunning") || keyword.startsWith("Prowl")) { || keyword.startsWith("Freerunning") || keyword.startsWith("Prowl")
final String[] k = keyword.split(":"); || keyword.startsWith("Sneak") || keyword.startsWith("Cleave")) {
final String manacost = k[1]; sbBefore.append(formatKeywordWithCost(inst));
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(")");
sbBefore.append("\r\n"); sbBefore.append("\r\n");
} else if (keyword.startsWith("Multikicker")) { } else if (keyword.startsWith("Multikicker")) {
final String[] n = keyword.split(":"); 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("Escape") || keyword.startsWith("Foretell:")
|| keyword.startsWith("Disturb") || keyword.startsWith("Overload") || keyword.startsWith("Disturb") || keyword.startsWith("Overload")
|| keyword.startsWith("Plot") || keyword.startsWith("Mayhem")) { || keyword.startsWith("Plot") || keyword.startsWith("Mayhem")) {
final String[] k = keyword.split(":"); sbAfter.append(formatKeywordWithCost(inst));
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("\r\n"); sbAfter.append("\r\n");
} else if (keyword.equals("Gift")) { } else if (keyword.equals("Gift")) {
sbBefore.append(keyword); sbBefore.append(keyword);
@@ -3456,6 +3431,26 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr
return sb; 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) { private String formatSpellAbility(final SpellAbility sa) {
final StringBuilder sb = new StringBuilder(); final StringBuilder sb = new StringBuilder();
sb.append(sa.toString()).append("\r\n\r\n"); sb.append(sa.toString()).append("\r\n\r\n");

View File

@@ -3621,6 +3621,31 @@ public class CardFactoryUtil {
sa.setSVar("ScavengeX", "Exiled$CardPower"); sa.setSVar("ScavengeX", "Exiled$CardPower");
sa.setIntrinsic(intrinsic); sa.setIntrinsic(intrinsic);
inst.addSpellAbility(sa); 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")) { } else if (keyword.startsWith("Station")) {
String effect = "AB$ PutCounter | Cost$ tapXType<1/Creature.Other> | Defined$ Self " + String effect = "AB$ PutCounter | Cost$ tapXType<1/Creature.Other> | Defined$ Self " +
"| CounterType$ CHARGE | CounterNum$ StationX | SorcerySpeed$ True " + "| 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."), 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."), 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."), 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."), 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."), 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."), 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."), 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."), 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."), 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, Overload,
Prowl, Prowl,
Plotted, Plotted,
Sneak,
Spectacle, Spectacle,
Surge, Surge,
Warp, Warp,

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
Name:Raphael's Technique
ManaCost:4 R R
Types:Instant
K:Sneak:2 R
A:SP$ Discard | Mode$ Hand | Defined$ Player | Optional$ True | RememberDiscardingPlayers$ True | SubAbility$ DBDraw | SpellDescription$ Each player may discard their hand and draw seven cards.
SVar:DBDraw:DB$ Draw | Defined$ Remembered | NumCards$ 7 | SubAbility$ DBCleanup
SVar:DBCleanup:DB$ Cleanup | ClearRemembered$ True
Oracle:Sneak {2}{R} (You may cast this spell for {2}{R} if you also return an unblocked attacker you control to hand during the declare blockers step.)\nEach player may discard their hand and draw seven cards.