diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index d9b8f9fefab..29ab83127dc 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -948,6 +948,20 @@ public class AiController { return 1; } + if (a.getHostCard().equals(b.getHostCard()) && a.getApi() == b.getApi() + && a.getPayCosts() != null && b.getPayCosts() != null) { + // Cheaper Spectacle costs should be preferred + // FIXME: Any better way to identify that these are the same ability, one with Spectacle and one not? + // (looks like it's not a full-fledged alternative cost as such, and is not processed with other alt costs) + if (a.isSpectacle() && !b.isSpectacle() + && a.getPayCosts().getTotalMana().getCMC() < b.getPayCosts().getTotalMana().getCMC()) { + return 1; + } else if (b.isSpectacle() && !a.isSpectacle() + && b.getPayCosts().getTotalMana().getCMC() < a.getPayCosts().getTotalMana().getCMC()) { + return 1; + } + } + a1 += getSpellAbilityPriority(a); b1 += getSpellAbilityPriority(b); diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtil.java b/forge-ai/src/main/java/forge/ai/ComputerUtil.java index ebfd378dfce..96ec1f25169 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtil.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtil.java @@ -20,6 +20,7 @@ package forge.ai; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.collect.*; +import forge.ai.ability.ChooseGenericEffectAi; import forge.ai.ability.ProtectAi; import forge.ai.ability.TokenAi; import forge.card.CardType; @@ -986,6 +987,11 @@ public class ComputerUtil { return true; } + if (card.hasKeyword(Keyword.RIOT) && ChooseGenericEffectAi.preferHasteForRiot(sa, ai)) { + // Planning to choose Haste for Riot, so do this in Main 1 + return true; + } + // if we have non-persistent mana in our pool, would be good to try to use it and not waste it if (ai.getManaPool().willManaBeLostAtEndOfPhase()) { boolean canUseToPayCost = false; diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilAbility.java b/forge-ai/src/main/java/forge/ai/ComputerUtilAbility.java index 366e53865a4..ba42c31cf9e 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilAbility.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilAbility.java @@ -97,9 +97,27 @@ public class ComputerUtilAbility { final List newAbilities = Lists.newArrayList(); for (SpellAbility sa : originList) { sa.setActivatingPlayer(player); - //add alternative costs as additional spell abilities + + // determine which alternative costs are cheaper than the original and prioritize them + List saAltCosts = GameActionUtil.getAlternativeCosts(sa, player); + List priorityAltSa = Lists.newArrayList(); + List otherAltSa = Lists.newArrayList(); + for (SpellAbility altSa : saAltCosts) { + if (altSa.getPayCosts() == null || sa.getPayCosts() == null) { + otherAltSa.add(altSa); + } else if (sa.getPayCosts().isOnlyManaCost() + && altSa.getPayCosts().isOnlyManaCost() && sa.getPayCosts().getTotalMana().compareTo(altSa.getPayCosts().getTotalMana()) == 1) { + // the alternative cost is strictly cheaper, so why not? (e.g. Omniscience etc.) + priorityAltSa.add(altSa); + } else { + otherAltSa.add(altSa); + } + } + + // add alternative costs as additional spell abilities + newAbilities.addAll(priorityAltSa); newAbilities.add(sa); - newAbilities.addAll(GameActionUtil.getAlternativeCosts(sa, player)); + newAbilities.addAll(otherAltSa); } final List result = Lists.newArrayList(); diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCombat.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCombat.java index 9fbd2ab58bc..e2393972f59 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCombat.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCombat.java @@ -1052,11 +1052,15 @@ public class ComputerUtilCombat { if (!ability.hasParam("CounterType") || !ability.getParam("CounterType").equals("P1P1")) { continue; } - + if (ability.hasParam("Monstrosity") && blocker.isMonstrous()) { - continue; + continue; } - + + if (ability.hasParam("Adapt") && blocker.getCounters(CounterType.P1P1) > 0) { + continue; + } + if (ComputerUtilCost.canPayCost(ability, blocker.getController())) { int pBonus = AbilityUtils.calculateAmount(ability.getHostCard(), ability.getParam("CounterNum"), ability); if (pBonus > 0) { @@ -1224,11 +1228,15 @@ public class ComputerUtilCombat { if (!ability.hasParam("CounterType") || !ability.getParam("CounterType").equals("P1P1")) { continue; } - + if (ability.hasParam("Monstrosity") && blocker.isMonstrous()) { - continue; + continue; } - + + if (ability.hasParam("Adapt") && blocker.getCounters(CounterType.P1P1) > 0) { + continue; + } + if (ComputerUtilCost.canPayCost(ability, blocker.getController())) { int tBonus = AbilityUtils.calculateAmount(ability.getHostCard(), ability.getParam("CounterNum"), ability); if (tBonus > 0) { @@ -1442,11 +1450,15 @@ public class ComputerUtilCombat { if (!ability.hasParam("CounterType") || !ability.getParam("CounterType").equals("P1P1")) { continue; } - + if (ability.hasParam("Monstrosity") && attacker.isMonstrous()) { - continue; + continue; } - + + if (ability.hasParam("Adapt") && blocker != null && blocker.getCounters(CounterType.P1P1) > 0) { + continue; + } + if (!ability.getPayCosts().hasTapCost() && ComputerUtilCost.canPayCost(ability, attacker.getController())) { int pBonus = AbilityUtils.calculateAmount(ability.getHostCard(), ability.getParam("CounterNum"), ability); if (pBonus > 0) { @@ -1675,11 +1687,15 @@ public class ComputerUtilCombat { if (!ability.hasParam("CounterType") || !ability.getParam("CounterType").equals("P1P1")) { continue; } - + if (ability.hasParam("Monstrosity") && attacker.isMonstrous()) { - continue; + continue; } - + + if (ability.hasParam("Adapt") && blocker.getCounters(CounterType.P1P1) > 0) { + continue; + } + if (!ability.getPayCosts().hasTapCost() && ComputerUtilCost.canPayCost(ability, attacker.getController())) { int tBonus = AbilityUtils.calculateAmount(ability.getHostCard(), ability.getParam("CounterNum"), ability); if (tBonus > 0) { diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java index 32d12449bdd..fb44ff4e31c 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java @@ -11,6 +11,7 @@ import forge.game.card.*; import forge.game.card.CardPredicates.Presets; import forge.game.combat.Combat; import forge.game.cost.*; +import forge.game.keyword.Keyword; import forge.game.player.Player; import forge.game.spellability.Spell; import forge.game.spellability.SpellAbility; @@ -74,7 +75,7 @@ public class ComputerUtilCost { final CounterType type = remCounter.counter; if (!part.payCostFromSource()) { - if (type.name().equals("P1P1")) { + if (CounterType.P1P1.equals(type)) { return false; } continue; @@ -105,7 +106,8 @@ public class ComputerUtilCost { } //don't kill the creature - if (type.name().equals("P1P1") && source.getLethalDamage() <= 1) { + if (CounterType.P1P1.equals(type) && source.getLethalDamage() <= 1 + && !source.hasKeyword(Keyword.UNDYING)) { return false; } } diff --git a/forge-ai/src/main/java/forge/ai/ability/ChooseGenericEffectAi.java b/forge-ai/src/main/java/forge/ai/ability/ChooseGenericEffectAi.java index af986809b36..39541df0f28 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChooseGenericEffectAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChooseGenericEffectAi.java @@ -6,6 +6,8 @@ import java.util.Map; import com.google.common.base.Predicate; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; +import com.google.common.collect.Sets; + import forge.ai.ComputerUtilAbility; import forge.ai.ComputerUtilCard; @@ -24,6 +26,8 @@ import forge.game.card.CounterType; import forge.game.combat.Combat; import forge.game.combat.CombatUtil; import forge.game.cost.Cost; +import forge.game.keyword.Keyword; +import forge.game.phase.PhaseType; import forge.game.player.Player; import forge.game.spellability.AbilitySub; import forge.game.spellability.SpellAbility; @@ -38,7 +42,7 @@ public class ChooseGenericEffectAi extends SpellAbilityAi { protected boolean checkAiLogic(final Player ai, final SpellAbility sa, final String aiLogic) { if ("Khans".equals(aiLogic) || "Dragons".equals(aiLogic)) { return true; - } else if (aiLogic.startsWith("Fabricate")) { + } else if (aiLogic.startsWith("Fabricate") || "Riot".equals(aiLogic)) { return true; } else if ("Pump".equals(aiLogic) || "BestOption".equals(aiLogic)) { for (AbilitySub sb : sa.getAdditionalAbilityList("Choices")) { @@ -344,7 +348,56 @@ public class ChooseGenericEffectAi extends SpellAbilityAi { if (!filtered.isEmpty()) { return filtered.get(0); } + } else if ("Riot".equals(logic)) { + SpellAbility counterSA = spells.get(0), hasteSA = spells.get(1); + return preferHasteForRiot(sa, player) ? hasteSA : counterSA; } return spells.get(0); // return first choice if no logic found } -} \ No newline at end of file + + public static boolean preferHasteForRiot(SpellAbility sa, Player player) { + // returning true means preferring Haste, returning false means preferring a +1/+1 counter + final Card host = sa.getHostCard(); + final Game game = host.getGame(); + final Card copy = CardUtil.getLKICopy(host); + copy.setLastKnownZone(player.getZone(ZoneType.Battlefield)); + + // check state it would have on the battlefield + CardCollection preList = new CardCollection(copy); + game.getAction().checkStaticAbilities(false, Sets.newHashSet(copy), preList); + // reset again? + game.getAction().checkStaticAbilities(false); + + // can't gain counters, use Haste + if (!copy.canReceiveCounters(CounterType.P1P1)) { + return true; + } + + // already has Haste, use counter + if (copy.hasKeyword(Keyword.HASTE)) { + return false; + } + + // not AI turn + if (!game.getPhaseHandler().isPlayerTurn(player)) { + return false; + } + + // not before Combat + if (!game.getPhaseHandler().getPhase().isBefore(PhaseType.COMBAT_DECLARE_ATTACKERS)) { + return false; + } + + // TODO check other opponents too if able + final Player opp = player.getWeakestOpponent(); + if (opp != null) { + // TODO add predict Combat Damage? + if (opp.getLife() < copy.getNetPower()) { + return true; + } + } + + // haste might not be good enough? + return false; + } +} diff --git a/forge-ai/src/main/java/forge/ai/ability/CountersMoveAi.java b/forge-ai/src/main/java/forge/ai/ability/CountersMoveAi.java index cd82c59a5e5..34827a4a273 100644 --- a/forge-ai/src/main/java/forge/ai/ability/CountersMoveAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/CountersMoveAi.java @@ -192,7 +192,7 @@ public class CountersMoveAi extends SpellAbilityAi { } // check for some specific AI preferences - if (src.hasStartOfKeyword("Graft") && "DontMoveCounterIfLethal".equals(src.getSVar("AIGraftPreference"))) { + if ("DontMoveCounterIfLethal".equals(sa.getParam("AILogic"))) { if (cType == CounterType.P1P1 && src.getNetToughness() - src.getTempToughnessBoost() - 1 <= 0) { return false; } @@ -333,11 +333,12 @@ public class CountersMoveAi extends SpellAbilityAi { // try to remove P1P1 from undying or evolve if (CounterType.P1P1.equals(cType)) { - if (card.hasKeyword("Undying") || card.hasKeyword("Evolve")) { + if (card.hasKeyword(Keyword.UNDYING) || card.hasKeyword(Keyword.EVOLVE) + || card.hasKeyword(Keyword.ADAPT)) { return true; } } - if (CounterType.M1M1.equals(cType) && card.hasKeyword("Persist")) { + if (CounterType.M1M1.equals(cType) && card.hasKeyword(Keyword.PERSIST)) { return true; } @@ -392,10 +393,10 @@ public class CountersMoveAi extends SpellAbilityAi { } if (cType != null) { - if (CounterType.P1P1.equals(cType) && card.hasKeyword("Undying")) { + if (CounterType.P1P1.equals(cType) && card.hasKeyword(Keyword.UNDYING)) { return false; } - if (CounterType.M1M1.equals(cType) && card.hasKeyword("Persist")) { + if (CounterType.M1M1.equals(cType) && card.hasKeyword(Keyword.PERSIST)) { return false; } diff --git a/forge-ai/src/main/java/forge/ai/ability/CountersPutAi.java b/forge-ai/src/main/java/forge/ai/ability/CountersPutAi.java index cb7d60ce4e5..8499e2921de 100644 --- a/forge-ai/src/main/java/forge/ai/ability/CountersPutAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/CountersPutAi.java @@ -311,6 +311,10 @@ public class CountersPutAi extends SpellAbilityAi { return false; } + if (sa.hasParam("Adapt") && source.getCounters(CounterType.P1P1) > 0) { + return false; + } + // TODO handle proper calculation of X values based on Cost int amount = AbilityUtils.calculateAmount(source, amountStr, sa); diff --git a/forge-ai/src/main/java/forge/ai/ability/CountersRemoveAi.java b/forge-ai/src/main/java/forge/ai/ability/CountersRemoveAi.java index 5c500ecd7f2..67b835ffc8a 100644 --- a/forge-ai/src/main/java/forge/ai/ability/CountersRemoveAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/CountersRemoveAi.java @@ -308,6 +308,30 @@ public class CountersRemoveAi extends SpellAbilityAi { } } if (mandatory) { + if (type.equals("P1P1")) { + // Try to target creatures with Adapt or similar + CardCollection adaptCreats = CardLists.filter(list, CardPredicates.hasKeyword(Keyword.ADAPT)); + if (!adaptCreats.isEmpty()) { + sa.getTargets().add(ComputerUtilCard.getWorstAI(adaptCreats)); + return true; + } + + // Outlast nice target + CardCollection outlastCreats = CardLists.filter(list, CardPredicates.hasKeyword(Keyword.OUTLAST)); + if (!outlastCreats.isEmpty()) { + // outlast cards often benefit from having +1/+1 counters, try not to remove last one + CardCollection betterTargets = CardLists.filter(outlastCreats, CardPredicates.hasCounter(CounterType.P1P1, 2)); + + if (!betterTargets.isEmpty()) { + sa.getTargets().add(ComputerUtilCard.getWorstAI(betterTargets)); + return true; + } + + sa.getTargets().add(ComputerUtilCard.getWorstAI(outlastCreats)); + return true; + } + } + sa.getTargets().add(ComputerUtilCard.getWorstAI(list)); return true; } diff --git a/forge-core/src/main/java/forge/item/PaperToken.java b/forge-core/src/main/java/forge/item/PaperToken.java index 59e2e0015ae..bc709dace7a 100644 --- a/forge-core/src/main/java/forge/item/PaperToken.java +++ b/forge-core/src/main/java/forge/item/PaperToken.java @@ -96,7 +96,9 @@ public class PaperToken implements InventoryItemFromSet, IPaperCard { build.add(keyword); } - build.add(edition.getCode()); + if (edition != null) { + build.add(edition.getCode()); + } // Should future image file names be all lower case? Instead of Up case sets? return StringUtils.join(build, "_").toLowerCase(); @@ -121,7 +123,7 @@ public class PaperToken implements InventoryItemFromSet, IPaperCard { @Override public String getName() { return name; } @Override public String toString() { return name; } - @Override public String getEdition() { return edition.getCode(); } + @Override public String getEdition() { return edition != null ? edition.getCode() : "???"; } @Override public int getArtIndex() { return 0; } // This might change however @Override public boolean isFoil() { return false; } @Override public CardRules getRules() { return card; } diff --git a/forge-core/src/main/java/forge/token/TokenDb.java b/forge-core/src/main/java/forge/token/TokenDb.java index f4275e4c86b..9c0ab01bec6 100644 --- a/forge-core/src/main/java/forge/token/TokenDb.java +++ b/forge-core/src/main/java/forge/token/TokenDb.java @@ -47,7 +47,7 @@ public class TokenDb implements ITokenDatabase { tokensByName.put(fullName, pt); return pt; } catch(Exception e) { - return null; + throw e; } } diff --git a/forge-game/src/main/java/forge/game/ForgeScript.java b/forge-game/src/main/java/forge/game/ForgeScript.java index 59870a830de..92f9e5311ce 100644 --- a/forge-game/src/main/java/forge/game/ForgeScript.java +++ b/forge-game/src/main/java/forge/game/ForgeScript.java @@ -152,7 +152,15 @@ public class ForgeScript { public static boolean spellAbilityHasProperty(SpellAbility sa, String property, Player sourceController, Card source, SpellAbility spellAbility) { - if (property.equals("Buyback")) { + if (property.equals("ManaAbility")) { + if (!sa.isManaAbility()) { + return false; + } + } else if (property.equals("nonManaAbility")) { + if (sa.isManaAbility()) { + return false; + } + } else if (property.equals("Buyback")) { if (!sa.isBuyBackAbility()) { return false; } diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java index 83e0e5e9396..36657751ce2 100644 --- a/forge-game/src/main/java/forge/game/Game.java +++ b/forge-game/src/main/java/forge/game/Game.java @@ -326,7 +326,7 @@ public class Game { * Gets the players who participated in match (regardless of outcome). * Use this in UI and after match calculations */ - public final List getRegisteredPlayers() { + public final PlayerCollection getRegisteredPlayers() { return allPlayers; } diff --git a/forge-game/src/main/java/forge/game/ability/effects/TokenEffect.java b/forge-game/src/main/java/forge/game/ability/effects/TokenEffect.java index 6ff9d2e7d4e..1636c8f6cc7 100644 --- a/forge-game/src/main/java/forge/game/ability/effects/TokenEffect.java +++ b/forge-game/src/main/java/forge/game/ability/effects/TokenEffect.java @@ -207,6 +207,8 @@ public class TokenEffect extends SpellAbilityEffect { if (result != null) { tokenName = result.getName(); + } else { + throw new RuntimeException("don't find Token for TokenScript: " + sa.getParam("TokenScript")); } return result; diff --git a/forge-game/src/main/java/forge/game/card/Card.java b/forge-game/src/main/java/forge/game/card/Card.java index 70b7dd66a2d..cd54eab13f7 100644 --- a/forge-game/src/main/java/forge/game/card/Card.java +++ b/forge-game/src/main/java/forge/game/card/Card.java @@ -1613,7 +1613,7 @@ public class Card extends GameEntity implements Comparable { || keyword.equals("Suspend") // for the ones without amounnt || keyword.equals("Hideaway") || keyword.equals("Ascend") || keyword.equals("Totem armor") || keyword.equals("Battle cry") - || keyword.equals("Devoid")){ + || keyword.equals("Devoid") || keyword.equals("Riot")){ sbLong.append(keyword + " (" + inst.getReminderText() + ")"); } else if (keyword.startsWith("Partner:")) { final String[] k = keyword.split(":"); @@ -1622,7 +1622,8 @@ public class Card extends GameEntity implements Comparable { || keyword.startsWith("Fabricate") || keyword.startsWith("Soulshift") || keyword.startsWith("Bushido") || keyword.startsWith("Crew") || keyword.startsWith("Tribute") || keyword.startsWith("Absorb") || keyword.startsWith("Graft") || keyword.startsWith("Fading") || keyword.startsWith("Vanishing") - || keyword.startsWith ("Afflict") || keyword.startsWith ("Poisonous") || keyword.startsWith("Rampage") + || keyword.startsWith("Afterlife") + || keyword.startsWith("Afflict") || keyword.startsWith ("Poisonous") || keyword.startsWith("Rampage") || keyword.startsWith("Renown") || keyword.startsWith("Annihilator") || keyword.startsWith("Devour")) { final String[] k = keyword.split(":"); sbLong.append(k[0] + " " + k[1] + " (" + inst.getReminderText() + ")"); @@ -1655,13 +1656,13 @@ public class Card extends GameEntity implements Comparable { sbLong.append(keyword); sbLong.append(" (" + Keyword.getInstance("Offering:"+ offeringType).getReminderText() + ")"); } else if (keyword.startsWith("Equip") || keyword.startsWith("Fortify") || keyword.startsWith("Outlast") - || keyword.startsWith("Unearth") || keyword.startsWith("Scavenge") + || keyword.startsWith("Unearth") || keyword.startsWith("Scavenge") || keyword.startsWith("Spectacle") || keyword.startsWith("Evoke") || keyword.startsWith("Bestow") || keyword.startsWith("Dash") || keyword.startsWith("Surge") || keyword.startsWith("Transmute") || keyword.startsWith("Suspend") || keyword.equals("Undaunted") || keyword.startsWith("Monstrosity") || keyword.startsWith("Embalm") || keyword.startsWith("Level up") || keyword.equals("Prowess") || keyword.startsWith("Eternalize") || keyword.startsWith("Reinforce") || keyword.startsWith("Champion") || keyword.startsWith("Prowl") - || keyword.startsWith("Amplify") || keyword.startsWith("Ninjutsu") + || keyword.startsWith("Amplify") || keyword.startsWith("Ninjutsu") || keyword.startsWith("Adapt") || keyword.startsWith("Cycling") || keyword.startsWith("TypeCycling")) { // keyword parsing takes care of adding a proper description } else if (keyword.startsWith("CantBeBlockedByAmount")) { diff --git a/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java b/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java index 9dcf81f51a9..65cebaa7191 100644 --- a/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java +++ b/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java @@ -985,11 +985,7 @@ public class CardFactoryUtil { return doXMath(cc.getLifeGainedByTeamThisTurn(), m, c); } if (sq[0].contains("LifeOppsLostThisTurn")) { - int lost = 0; - for (Player opp : cc.getOpponents()) { - lost += opp.getLifeLostThisTurn(); - } - return doXMath(lost, m, c); + return doXMath(cc.getOpponentLostLifeThisTurn(), m, c); } if (sq[0].equals("TotalDamageDoneByThisTurn")) { return doXMath(c.getTotalDamageDoneBy(), m, c); @@ -2047,6 +2043,12 @@ public class CardFactoryUtil { final String effect, final boolean optional, final boolean secondary, final boolean intrinsic, final String valid, final String zone) { SpellAbility repAb = AbilityFactory.getAbility(effect, card); + return createETBReplacement(card, layer, repAb, optional, secondary, intrinsic, valid, zone); + } + + private static ReplacementEffect createETBReplacement(final Card card, ReplacementLayer layer, + final SpellAbility repAb, final boolean optional, final boolean secondary, + final boolean intrinsic, final String valid, final String zone) { String desc = repAb.getDescription(); setupETBReplacementAbility(repAb); if (!intrinsic) { @@ -2137,6 +2139,20 @@ public class CardFactoryUtil { afflictTrigger.setOverridingAbility(AbilityFactory.getAbility(abStringAfflict, card)); inst.addTrigger(afflictTrigger); + } else if (keyword.startsWith("Afterlife")) { + final String k[] = keyword.split(":"); + final String name = StringUtils.join(k, " "); + + final StringBuilder sb = new StringBuilder(); + sb.append("Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self "); + sb.append("| Secondary$ True | TriggerDescription$ ").append(name); + sb.append(" (").append(inst.getReminderText()).append(")"); + final String effect = "DB$ Token | TokenAmount$ " + k[1] + " | TokenScript$ wb_1_1_spirit_flying"; + + final Trigger trigger = TriggerHandler.parseTrigger(sb.toString(), card, intrinsic); + + trigger.setOverridingAbility(AbilityFactory.getAbility(effect, card)); + inst.addTrigger(trigger); } else if (keyword.startsWith("Annihilator")) { final String[] k = keyword.split(":"); final String n = k[1]; @@ -2462,8 +2478,13 @@ public class CardFactoryUtil { inst.addTrigger(trigger); } else if (keyword.startsWith("Graft")) { - final String abStr = "DB$ MoveCounter | Source$ Self | " - + "Defined$ TriggeredCardLKICopy | CounterType$ P1P1 | CounterNum$ 1"; + final StringBuilder sb = new StringBuilder(); + sb.append("DB$ MoveCounter | Source$ Self | Defined$ TriggeredCardLKICopy"); + sb.append(" | CounterType$ P1P1 | CounterNum$ 1"); + + if (card.hasSVar("AIGraftPreference")) { + sb.append(" | AILogic$ ").append(card.getSVar("AIGraftPreference")); + } String trigStr = "Mode$ ChangesZone | ValidCard$ Creature.Other" + "| Origin$ Any | Destination$ Battlefield " @@ -2474,7 +2495,7 @@ public class CardFactoryUtil { + "may move a +1/+1 counter from this creature onto it."; final Trigger trigger = TriggerHandler.parseTrigger(trigStr, card, intrinsic); - trigger.setOverridingAbility(AbilityFactory.getAbility(abStr, card)); + trigger.setOverridingAbility(AbilityFactory.getAbility(sb.toString(), card)); inst.addTrigger(trigger); } else if (keyword.startsWith("Haunt")) { @@ -3471,6 +3492,23 @@ public class CardFactoryUtil { re.setOverridingAbility(saExile); inst.addReplacement(re); + } else if (keyword.startsWith("Riot")) { + final String choose = "DB$ GenericChoice | AILogic$ Riot | SpellDescription$ Riot"; + + final String counter = "DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | ETB$ True | CounterNum$ 1" + + " | SpellDescription$ Put a +1/+1 counter on it."; + final String haste = "DB$ Animate | Defined$ Self | Keywords$ Haste | Permanent$ True | SpellDescription$ Haste"; + + SpellAbility saChoose = AbilityFactory.getAbility(choose, card); + + List list = Lists.newArrayList(); + list.add((AbilitySub)AbilityFactory.getAbility(counter, card)); + list.add((AbilitySub)AbilityFactory.getAbility(haste, card)); + saChoose.setAdditionalAbilityList("Choices", list); + + ReplacementEffect cardre = createETBReplacement(card, ReplacementLayer.Other, saChoose, false, true, intrinsic, "Card.Self", ""); + + inst.addReplacement(cardre); } else if (keyword.startsWith("Saga")) { String sb = "etbCounter:LORE:1:no Condition:no desc"; final ReplacementEffect re = makeEtbCounter(sb, card, intrinsic); @@ -3512,7 +3550,7 @@ public class CardFactoryUtil { inst.addReplacement(cardre); } else if (keyword.equals("Unleash")) { - String effect = "DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | CounterNum$ 1 | SpellDescription$ Unleash (" + inst.getReminderText() + ")"; + String effect = "DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | ETB$ True | CounterNum$ 1 | SpellDescription$ Unleash (" + inst.getReminderText() + ")"; ReplacementEffect cardre = createETBReplacement(card, ReplacementLayer.Other, effect, true, true, intrinsic, "Card.Self", ""); @@ -3662,6 +3700,24 @@ public class CardFactoryUtil { inst.addSpellAbility(newSA); } + } else if (keyword.startsWith("Adapt")) { + final String[] k = keyword.split(":"); + final String magnitude = k[1]; + final String manacost = k[2]; + + String desc = "Adapt " + magnitude; + + String effect = "AB$ PutCounter | Cost$ " + manacost + " | ConditionPresent$ " + + "Card.Self+counters_EQ0_P1P1 | Adapt$ True | CounterNum$ " + magnitude + + " | CounterType$ P1P1 | StackDescription$ SpellDescription"; + + effect += "| SpellDescription$ " + desc + " (" + inst.getReminderText() + ")"; + + final SpellAbility sa = AbilityFactory.getAbility(effect, card); + sa.setIntrinsic(intrinsic); + + sa.setTemporary(!intrinsic); + inst.addSpellAbility(sa); } else if (keyword.equals("Aftermath") && card.getCurrentStateName().equals(CardStateName.RightSplit)) { // Aftermath does modify existing SA, and does not add new one @@ -4138,6 +4194,23 @@ public class CardFactoryUtil { sa.setTemporary(!intrinsic); inst.addSpellAbility(sa); + } else if (keyword.startsWith("Spectacle")) { + final String[] k = keyword.split(":"); + final Cost cost = new Cost(k[1], false); + final SpellAbility newSA = card.getFirstSpellAbility().copyWithDefinedCost(cost); + + newSA.setBasicSpell(false); + newSA.setSpectacle(true); + + String desc = "Spectacle " + cost.toSimpleString() + " (" + inst.getReminderText() + + ")"; + newSA.setDescription(desc); + + newSA.setIntrinsic(intrinsic); + + newSA.setTemporary(!intrinsic); + inst.addSpellAbility(newSA); + } else if (keyword.equals("Sunburst") && intrinsic) { final GameCommand sunburstCIP = new GameCommand() { private static final long serialVersionUID = 1489845860231758299L; diff --git a/forge-game/src/main/java/forge/game/card/CardProperty.java b/forge-game/src/main/java/forge/game/card/CardProperty.java index aac65619027..06cab7f3747 100644 --- a/forge-game/src/main/java/forge/game/card/CardProperty.java +++ b/forge-game/src/main/java/forge/game/card/CardProperty.java @@ -1632,6 +1632,11 @@ public class CardProperty { return false; } return card.getCastSA().isProwl(); + } else if (property.startsWith("spectacle")) { + if (card.getCastSA() == null) { + return false; + } + return card.getCastSA().isSpectacle(); } else if (property.equals("HasDevoured")) { if (card.getDevouredCards().isEmpty()) { return false; diff --git a/forge-game/src/main/java/forge/game/cost/Cost.java b/forge-game/src/main/java/forge/game/cost/Cost.java index 1d7606922c0..09f0b69b4d9 100644 --- a/forge-game/src/main/java/forge/game/cost/Cost.java +++ b/forge-game/src/main/java/forge/game/cost/Cost.java @@ -98,6 +98,8 @@ public class Cost implements Serializable { return true; } + + @SuppressWarnings("unchecked") public T getCostPartByType(Class costType) { for (CostPart p : getCostParts()) { if (costType.isInstance(p)) { diff --git a/forge-game/src/main/java/forge/game/keyword/Keyword.java b/forge-game/src/main/java/forge/game/keyword/Keyword.java index 72c45bc8589..ead951daf98 100644 --- a/forge-game/src/main/java/forge/game/keyword/Keyword.java +++ b/forge-game/src/main/java/forge/game/keyword/Keyword.java @@ -13,8 +13,10 @@ import forge.util.TextUtil; public enum Keyword { UNDEFINED(SimpleKeyword.class, false, ""), ABSORB(KeywordWithAmount.class, false, "If a source would deal damage to this creature, prevent %d of that damage."), + ADAPT(KeywordWithCostAndAmount.class, false, "If this creature has no +1/+1 counters on it, put {%2$d:+1/+1 counter} on it."), AFFINITY(KeywordWithType.class, false, "This spell costs you {1} less to cast for each %s you control."), AFFLICT(KeywordWithAmount.class, false, "Whenever this creature becomes blocked, defending player loses %d life."), + AFTERLIFE(KeywordWithAmount.class, false, "When this creature dies, create {%1$d:1/1 white and black Spirit creature token} with flying."), AFTERMATH(SimpleKeyword.class, false, "Cast this spell only from your graveyard. Then exile it."), AMPLIFY(KeywordWithAmountAndType.class, false, "As this creature enters the battlefield, put {%d:+1/+1 counter} on it for each %s card you reveal in your hand."), ANNIHILATOR(KeywordWithAmount.class, false, "Whenever this creature attacks, defending player sacrifices {%d:permanent}."), @@ -122,6 +124,7 @@ public enum Keyword { RENOWN(KeywordWithAmount.class, true, "When this creature deals combat damage to a player, if it isn't renowned, put {%d:+1/+1 counter} on it and it becomes renowned."), REPLICATE(KeywordWithCost.class, false, "As an additional cost to cast this spell, you may pay %s any number of times. If you do, copy it that many times. You may choose new targets for the copies."), RETRACE(SimpleKeyword.class, true, "You may cast this card from your graveyard by discarding a land card in addition to paying its other costs."), + RIOT(SimpleKeyword.class, false, "This creature enters the battlefield with your choice of a +1/+1 counter or haste."), 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."), SHADOW(SimpleKeyword.class, true, "This creature can block or be blocked by only creatures with shadow."), SHROUD(SimpleKeyword.class, true, "This can't be the target of spells or abilities."), @@ -129,6 +132,7 @@ public enum Keyword { 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."), SOULBOND(SimpleKeyword.class, true, "You may pair this creature with another unpaired creature when either enters the battlefield. They remain paired for as long as you control both of them"), SOULSHIFT(KeywordWithAmount.class, false, "When this creature dies, you may return target Spirit card with converted mana cost %d or less from your graveyard to your hand."), + SPECTACLE(KeywordWithCost.class, true, "You may cast this spell for its spectacle cost rather than its mana cost if an opponent lost life this turn."), SPLICE(KeywordWithCostAndType.class, false, "As you cast an %2$s spell, you may reveal this card from your hand and pay its splice cost. If you do, add this card's effects to that spell."), SPLIT_SECOND(SimpleKeyword.class, true, "As long as this spell is on the stack, players can't cast other spells or activate abilities that aren't mana abilities."), STORM(SimpleKeyword.class, false, "When you cast this spell, copy it for each other spell that was cast before it this turn. You may choose new targets for the copies."), diff --git a/forge-game/src/main/java/forge/game/player/Player.java b/forge-game/src/main/java/forge/game/player/Player.java index 98dbbe2edf9..bd00fca5986 100644 --- a/forge-game/src/main/java/forge/game/player/Player.java +++ b/forge-game/src/main/java/forge/game/player/Player.java @@ -260,6 +260,10 @@ public class Player extends GameEntity implements Comparable { return game.getPlayers().filter(PlayerPredicates.isOpponentOf(this)); } + public final PlayerCollection getRegisteredOpponents() { + return game.getRegisteredPlayers().filter(PlayerPredicates.isOpponentOf(this)); + } + public void updateOpponentsForView() { view.updateOpponents(this); } @@ -2037,8 +2041,7 @@ public class Player extends GameEntity implements Comparable { } public final int getBloodthirstAmount() { - return Aggregates.sum(Iterables.filter( - game.getRegisteredPlayers(), PlayerPredicates.isOpponentOf(this)), Accessors.FN_GET_ASSIGNED_DAMAGE); + return Aggregates.sum(getRegisteredOpponents(), Accessors.FN_GET_ASSIGNED_DAMAGE); } public final boolean hasSurge() { @@ -2047,6 +2050,14 @@ public class Player extends GameEntity implements Comparable { return !CardLists.filterControlledBy(game.getStack().getSpellsCastThisTurn(), list).isEmpty(); } + public final int getOpponentLostLifeThisTurn() { + int lost = 0; + for (Player opp : getRegisteredOpponents()) { + lost += opp.getLifeLostThisTurn(); + } + return lost; + } + public final boolean hasProwl(final String type) { if (prowl.contains("AllCreatureTypes")) { return true; diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java index a1cf5a993fa..853c29b21f4 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -96,6 +96,7 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit private int sourceTrigger = -1; private List triggerRemembered = Lists.newArrayList(); + // TODO use enum for the flags private boolean flashBackAbility = false; private boolean aftermath = false; private boolean cycling = false; @@ -103,6 +104,7 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit private boolean evoke = false; private boolean prowl = false; private boolean surge = false; + private boolean spectacle = false; private boolean offering = false; private boolean emerge = false; private boolean morphup = false; @@ -1121,6 +1123,14 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit surge = isSurge; } + public final boolean isSpectacle() { + return spectacle; + } + + public final void setSpectacle(final boolean isSpectacle) { + spectacle = isSpectacle; + } + public CardCollection getTappedForConvoke() { return tappedForConvoke; } diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java b/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java index 111659b7e03..e992a95a90c 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java @@ -419,6 +419,11 @@ public class SpellAbilityRestriction extends SpellAbilityVariables { return false; } } + if (sa.isSpectacle()) { + if (activator.getOpponentLostLifeThisTurn() <= 0) { + return false; + } + } if (isDesert()) { if (!activator.hasDesert()) { return false; diff --git a/forge-gui/res/cardsfolder/upcoming/aeromunculus.txt b/forge-gui/res/cardsfolder/upcoming/aeromunculus.txt new file mode 100644 index 00000000000..a0028624591 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/aeromunculus.txt @@ -0,0 +1,8 @@ +Name:Aeromunculus +ManaCost:1 G U +Types:Creature Homunculus Mutant +PT:2/3 +K:Flying +K:Adapt:1:2 G U +DeckHas:Ability$Counters +Oracle:Flying\n{2}{G}{U}: Adapt 1. (If this creature has no +1/+1 counters on it, put a +1/+1 counter on it.) diff --git a/forge-gui/res/cardsfolder/upcoming/frenzied_arynx.txt b/forge-gui/res/cardsfolder/upcoming/frenzied_arynx.txt new file mode 100644 index 00000000000..b9169b6b113 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/frenzied_arynx.txt @@ -0,0 +1,8 @@ +Name:Frenzied Arynx +ManaCost:2 R G +Types:Creature Cat Beast +PT:3/3 +K:Riot +K:Trample +A:AB$ Pump | Cost$ 4 R G | NumAtt$ +3 | SpellDescription$ CARDNAME gets +3/+0 until end of turn. +Oracle:Riot (This creature enters the battlefield with your choice of a +1/+1 counter or haste.)\nTrample\n{4}{R}{G}: Frenzied Arynx gets +3/+0 until end of turn. diff --git a/forge-gui/res/cardsfolder/upcoming/gruul_spellbreaker.txt b/forge-gui/res/cardsfolder/upcoming/gruul_spellbreaker.txt new file mode 100644 index 00000000000..77eb6feaeeb --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/gruul_spellbreaker.txt @@ -0,0 +1,8 @@ +Name:Gruul Spellbreaker +ManaCost:1 R G +Types:Creature Ogre Warrior +PT:3/3 +K:Riot +K:Trample +S:Mode$ Continuous | Affected$ You,Self | AddKeyword$ Hexproof | Condition$ PlayerTurn | Description$ As long as it's your turn, you and CARDNAME have hexproof. +Oracle:Riot (This creature enters the battlefield with your choice of a +1/+1 counter or haste.)\nTrample\nAs long as it's your turn, you and Gruul Spellbreaker have hexproof. diff --git a/forge-gui/res/cardsfolder/upcoming/imperious_oligarch.txt b/forge-gui/res/cardsfolder/upcoming/imperious_oligarch.txt new file mode 100644 index 00000000000..13e0bc6549e --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/imperious_oligarch.txt @@ -0,0 +1,7 @@ +Name:Imperious Oligarch +ManaCost:W B +Types:Creature Human Cleric +PT:2/1 +K:Vigilance +K:Afterlife:1 +Oracle:Vigilance\nAfterlife 1 (When this creature dies, create a 1/1 white and black Spirit creature token with flying.) diff --git a/forge-gui/res/cardsfolder/upcoming/rafter_demon.txt b/forge-gui/res/cardsfolder/upcoming/rafter_demon.txt new file mode 100644 index 00000000000..a052536a4b1 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/rafter_demon.txt @@ -0,0 +1,8 @@ +Name:Rafter Demon +ManaCost:2 B R +Types:Creature Demon +PT:4/2 +K:Spectacle:3 B R +T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self+spectacle | Execute$ TrigDiscard | TriggerDescription$ When CARDNAME enters the battlefield, if its spectacle cost was paid, each opponent discards a card. +SVar:TrigDiscard:DB$ Discard | Defined$ Player.Opponent | NumCards$ 1 | Mode$ TgtChoose +Oracle:Spectacle {3}{B}{R} (You may cast this spell for its spectacle cost rather than its mana cost if an opponent lost life this turn.)\nWhen Rafter Demon enters the battlefield, if its spectacle cost was paid, each opponent discards a card. diff --git a/forge-gui/res/cardsfolder/upcoming/simic_ascendancy.txt b/forge-gui/res/cardsfolder/upcoming/simic_ascendancy.txt new file mode 100644 index 00000000000..087d3fcee62 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/simic_ascendancy.txt @@ -0,0 +1,12 @@ +Name:Simic Ascendancy +ManaCost:G U +Types:Enchantment +A:AB$ PutCounter | Cost$ 1 G U | CounterType$ P1P1 | CounterNum$ 1 | ValidTgts$ Creature.YouCtrl | TgtPrompt$ Select target creature you control | SpellDescription$ Put a +1/+1 counter on target creature you control. +T:Mode$ CounterAddedOnce | ValidCard$ Creature.YouCtrl | TriggerZones$ Battlefield | CounterType$ P1P1 | Execute$ TrigPutCounter | TriggerDescription$ Whenever one or more +1/+1 counters are put on a creature you control, put that many growth counters on CARDNAME. +SVar:TrigPutCounter:DB$ PutCounter | CounterType$ GROWTH | CounterNum$ X | References$ X +T:Mode$ Phase | Phase$ Upkeep | ValidPlayer$ You | TriggerZones$ Battlefield | IsPresent$ Card.Self+counters_GE20_GROWTH | Execute$ TrigWinGame | TriggerDescription$ At the beginning of your upkeep, if CARDNAME has twenty or more growth counters on it, you win the game. +SVar:TrigWinGame:DB$WinsGame | Defined$ You +SVar:X:TriggerCount$Amount +DeckHints:Ability$Counters +DeckHas:Ability$Counters +Oracle:{1}{G}{U}: Put a +1/+1 counter on target creature you control.\nWhenever one or more +1/+1 counters are put on a creature you control, put that many growth counters on Simic Ascendancy.\nAt the beginning of your upkeep, if Simic Ascendancy has twenty or more growth counters on it, you win the game. diff --git a/forge-gui/res/cardsfolder/upcoming/sphinxs_insight.txt b/forge-gui/res/cardsfolder/upcoming/sphinxs_insight.txt new file mode 100644 index 00000000000..ecd8f6980d0 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/sphinxs_insight.txt @@ -0,0 +1,6 @@ +Name:Sphinx's Insight +ManaCost:2 W U +Types:Instant +A:SP$ Draw | Cost$ 2 W U | NumCards$ 2 | SubAbility$ DBLife | StackDescription$ SpellDescription | SpellDescription$ Draw two cards. +SVar:DBLife:DB$ GainLife | LifeAmount$ 2 | ConditionPlayerTurn$ True | ConditionPhases$ Main1,Main2 | SpellDescription$ Addendum - If you cast this spell during your main phase, you gain 2 life. +Oracle:Draw two cards.\nAddendum - If you cast this spell during your main phase, you gain 2 life. diff --git a/forge-gui/res/cardsfolder/upcoming/tithe_taker.txt b/forge-gui/res/cardsfolder/upcoming/tithe_taker.txt new file mode 100644 index 00000000000..5de300d42f5 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/tithe_taker.txt @@ -0,0 +1,7 @@ +Name:Tithe Taker +ManaCost:1 W +Types:Creature Human Soldier +PT:2/1 +K:Afterlife:1 +S:Mode$ RaiseCost | ValidCard$ Card | Activator$ Opponent | ValidSpell$ Spell,Activated.nonManaAbility | Amount$ 1 | Condition$ PlayerTurn | Description$ During your turn, spells your opponents cast cost {1} more to cast and abilities your opponents activate cost {1} more to activate unless they're mana abilities. +Oracle:During your turn, spells your opponents cast cost {1} more to cast and abilities your opponents activate cost {1} more to activate unless they're mana abilities.\nAfterlife 1 (When this creature dies, create a 1/1 white and black Spirit creature token with flying.) diff --git a/forge-gui/res/cardsfolder/upcoming/zegana_utopian_speaker.txt b/forge-gui/res/cardsfolder/upcoming/zegana_utopian_speaker.txt new file mode 100644 index 00000000000..d859b1721d3 --- /dev/null +++ b/forge-gui/res/cardsfolder/upcoming/zegana_utopian_speaker.txt @@ -0,0 +1,11 @@ +Name:Zegana, Utopian Speaker +ManaCost:2 G U +Types:Legendary Creature Merfolk Wizard +PT:4/4 +T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | IsPresent$ Creature.Other+YouCtrl+counters_GE1_P1P1 | Execute$ TrigDraw | TriggerDescription$ When CARDNAME enters the battlefield, if you control another creature with a +1/+1 counter on it, draw a card. +SVar:TrigDraw:DB$ Draw | NumCards$ 1 +K:Adapt:4:4 G U +S:Mode$ Continuous | Affected$ Creature.YouCtrl+counters_GE1_P1P1 | AddKeyword$ Trample | Description$ Each creature you control with a +1/+1 counter on it has trample. +DeckHas:Ability$Counters +DeckHints:Ability$Counters +Oracle:When Zegana, Utopian Speaker enters the battlefield, if you control another creature with a +1/+1 counter on it, draw a card.\n{4}{G}{U}: Adapt 4. (If this creature has no +1/+1 counters on it, put four +1/+1 counters on it.)\nEach creature you control with a +1/+1 counter on it has trample. diff --git a/forge-gui/res/tokenscripts/wb_1_1_spirit_flying.txt b/forge-gui/res/tokenscripts/wb_1_1_spirit_flying.txt new file mode 100644 index 00000000000..9fe27496ea8 --- /dev/null +++ b/forge-gui/res/tokenscripts/wb_1_1_spirit_flying.txt @@ -0,0 +1,7 @@ +Name:Spirit +ManaCost:no cost +Types:Creature Spirit +Colors:white,black +PT:1/1 +K:Flying +Oracle:Flying