Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 69 additions & 29 deletions forge-game/src/main/java/forge/game/GameActionUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/
package forge.game;

import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
Expand Down Expand Up @@ -52,6 +53,8 @@
import forge.game.zone.ZoneType;
import forge.util.Lang;
import forge.util.TextUtil;
import forge.util.collect.FCollection;
import forge.util.collect.FCollectionView;


/**
Expand Down Expand Up @@ -269,35 +272,6 @@ public static final List<SpellAbility> getAlternativeCosts(final SpellAbility sa
foretold.putParam("AfterDescription", "(Foretold)");
alternatives.add(foretold);
}

// some needs to check after ability was put on the stack
// Currently this is only checked for Toolbox and that only cares about creature spells
if (source.isCreature() && game.getAction().hasStaticAbilityAffectingZone(ZoneType.Stack, StaticAbilityLayer.ABILITIES)) {
Zone oldZone = source.getLastKnownZone();
Card blitzCopy = source;
if (!source.isLKI()) {
blitzCopy = CardUtil.getLKICopy(source);
}
blitzCopy.setLastKnownZone(game.getStackZone());
lkicheck = true;

blitzCopy.clearStaticChangedCardKeywords(false);
CardCollection preList = new CardCollection(blitzCopy);
game.getAction().checkStaticAbilities(false, Sets.newHashSet(blitzCopy), preList);

// currently only for Keyword BLitz, but should affect Dash probably too
for (final KeywordInterface inst : blitzCopy.getKeywords(Keyword.BLITZ)) {
// TODO with mana value 4 or greater has blitz.
for (SpellAbility iSa : inst.getAbilities()) {
// do only non intrinsic
if (!iSa.isIntrinsic()) {
alternatives.add(iSa);
}
}
}
// need to reset to Old Zone, or canPlay would fail
blitzCopy.setLastKnownZone(oldZone);
}
Comment on lines -272 to -300
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic was moved, mostly unchanged, into getAlternativeCostsGrantedByStaticAbilities.

}

// reset static abilities
Expand Down Expand Up @@ -377,6 +351,72 @@ public static final List<SpellAbility> getAlternativeCosts(final SpellAbility sa
}
return alternatives;
}

/**
* Checks a card to see if it qualifies to receive keywords from static
* abilities that grant alternative casting costs to spells on the stack, for
* example, Toolbox's ability that gives blitz to creature spells with mana
* value 4 or greater, and Hunting Velociraptor's ability that gives prowl to
* Dinosaur spells.
*
* @param source The card to check eligibility for.
* @return Zero or more spell abilities, each representing an alternative
* casting cost.
*/
public static final FCollectionView<SpellAbility> getAlternativeCostsGrantedByStaticAbilities(Card source) {
final FCollection<SpellAbility> alternatives = new FCollection<SpellAbility>();
final Game game = source.getGame();

if (!game.getAction().hasStaticAbilityAffectingZone(ZoneType.Stack, StaticAbilityLayer.ABILITIES)) {
return alternatives;
}
Comment on lines +370 to +372
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous logic checked if the source is a creature, but I removed that here, because the restriction is already checked when applying the static ability in checkStaticAbilities, below.

This will allow static abilities that grant alternative costs to non-creature spells to function, also.


// double freeze tracker, so it doesn't update view
game.getTracker().freeze();

Zone oldZone = source.getLastKnownZone();
Card creatureCandidate = source; // Candidate to receive keyword.

if (!source.isLKI()) {
creatureCandidate = CardUtil.getLKICopy(source);
}
Comment on lines +374 to +382
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I admit I don't really understand the concept of Last Known Information, so I've tried to leave it untouched, but I noticed that some of this logic was being run before the static ability checks were happening in the old location, so I included it here in case it's necessary.

There's also corresponding code that is executed afterward, which book-ends the logic.


creatureCandidate.setLastKnownZone(game.getStackZone());
creatureCandidate.clearStaticChangedCardKeywords(false);
CardCollection preList = new CardCollection(creatureCandidate);
game.getAction().checkStaticAbilities(false, Sets.newHashSet(creatureCandidate), preList);

for (final KeywordInterface keywordInterface : creatureCandidate.getUnhiddenKeywords()) {
try {
// Try to find the keyword in the list of alternative cost keywords.
// If the operation doesn't throw an IllegalArgumentException, the keyword
// is an alternative cost.
AlternativeCost.valueOf(keywordInterface.getKeyword().toString());

for (SpellAbility iSa : keywordInterface.getAbilities()) {
// do only non intrinsic
if (!iSa.isIntrinsic()) {
iSa.setHostCard(source);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is new. Without it, the alternative cost isn't copied for additional CardPlayOptions, which means it can only be paid when casting from the Hand.

alternatives.add(iSa);
}
}
} catch (IllegalArgumentException e) {
// The keyword interface isn't for an alternative cost keyword.
continue;
}
Comment on lines +403 to +406
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some places I've worked frown upon using exceptions this way.

If it violates convention here, I'll write a new function to verify the keyword represents an alternative cost rather than calling valueOf on the AlternativeCost enum.

}

// need to reset to Old Zone, or canPlay fails.
creatureCandidate.setLastKnownZone(oldZone);

game.getAction().checkStaticAbilities(false);
// clear delayed changes, this check should not have updated the view.
game.getTracker().clearDelayed();
// need to unfreeze tracker.
game.getTracker().unfreeze();

return alternatives;
}

public static List<OptionalCostValue> getOptionalCostValues(final SpellAbility sa) {
final List<OptionalCostValue> costs = Lists.newArrayList();
Expand Down
3 changes: 2 additions & 1 deletion forge-game/src/main/java/forge/game/card/Card.java
Original file line number Diff line number Diff line change
Expand Up @@ -7153,7 +7153,8 @@ public Game getGame() {
public List<SpellAbility> getAllPossibleAbilities(final Player player, final boolean removeUnplayable) {
CardState oState = getState(CardStateName.Original);
final List<SpellAbility> abilities = Lists.newArrayList();
for (SpellAbility sa : getSpellAbilities()) {
FCollectionView<SpellAbility> spellAbilitiesFromStaticAbilities = GameActionUtil.getAlternativeCostsGrantedByStaticAbilities(this);
for (SpellAbility sa : Iterables.concat(getSpellAbilities(), spellAbilitiesFromStaticAbilities)) {
Comment on lines +7156 to +7157
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with your change you can't Prowl/Blitz a Modular DFC (see below)

that's why called by GameActionUtil.getAlternativeCosts is better

//adventure spell check
if (isAdventureCard() && sa.isAdventure()) {
if (getExiledWith() != null && getExiledWith().equals(this) && CardStateName.Adventure.equals(getExiledWith().getCurrentStateName()))
Expand Down
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/h/henzie_toolbox_torre.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Name:Henzie "Toolbox" Torre
ManaCost:B R G
Types: Legendary Creature Devil Rogue
PT:3/3
S:Mode$ Continuous | Affected$ Creature.YouCtrl | AffectedZone$ Stack | AddKeyword$ Blitz:CardManaCost:Spell.Creature+cmcGE4 | Description$ Each creature spell you cast with mana value 4 or greater has blitz. The blitz cost is equal to its mana cost. (You may choose to cast that spell for its blitz cost. If you do, it gains haste and "When this creature dies, draw a card." Sacrifice it at the beginning of the next end step.)
S:Mode$ Continuous | Affected$ Creature.YouCtrl+cmcGE4 | AffectedZone$ Stack | AddKeyword$ Blitz:CardManaCost:Spell.Creature | Description$ Each creature spell you cast with mana value 4 or greater has blitz. The blitz cost is equal to its mana cost. (You may choose to cast that spell for its blitz cost. If you do, it gains haste and "When this creature dies, draw a card." Sacrifice it at the beginning of the next end step.)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous implementation was so close to being correct!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Check is for MDFC

is the affected checked again when casting the Backside?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look into this later today (it's currently 3am local time for me).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Henzie, it also needs to check the Spell Mana Value because for X Creature Spells

so you can only Blitz them when X is high enough

Copy link
Author

@tchataway tchataway Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that wasn't happening at all before. The exclusive restriction wasn't defined in the right place. I moved it to the Affected param, and now it actually does check it (i.e. Affected Creature.YouCtrl+cmcGE4).

Also, the wording of Henzie is mana value, not mana cost, so it will always consider X to be zero when checking this restriction.

Copy link
Author

@tchataway tchataway Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately you picked a pretty complex area as your first project :/

Just my luck! 😆

We haven't been happy with the MayPlay / alternative cost handling for quite some time, but it has also proven rather difficult to fix smaller aspects of it without breaking something else at the same time...(so it has been left alone mostly for a bigger refactor)

Right, fair enough. It is turning out to be much hairier than anticipated. Would it be better to just shelve this in lieu of a more comprehensive refactor later, then?

For your X interaction it's this:

202.3e. When calculating the mana value of an object with an {X} in its mana cost, X is treated as 0 while the object is not on the stack, and X is treated as the number chosen for it while the object is on the stack.

Thank you! I was very confused but this makes sense. I'm obviously very new to the codebase so I'm not clear on how Forge lines things up relative to the "proposal to cast a spell". When the user clicks a card in their hand, for example, Forge appears to get all costs and alternative costs at that point, and present them as a list for the user. For X cost creatures in Toolbox's case, should it present the Blitz option the way it normally would, but with X in the cost, then prompt for X, then check the total mana value of the spell on the stack and either cancel/prompt for mana?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for human it goes into this line:

// because of Selective Snare do announceType first
final boolean prerequisitesMet = announceType()
&& announceValuesLikeX()
&& ability.checkRestrictions(human)
&& (!mayChooseTargets || ability.setupTargets()) // if you can choose targets, then do choose them.
&& ability.canCastTiming(human)
&& ability.isLegalAfterStack()

where it checks this:

public boolean isLegalAfterStack() {
if (!matchesValidParam("ValidAfterStack", this)) {
return false;
}
return true;
}

which is set by Blitz:Cost:* for this:

} else if (keyword.startsWith("Blitz")) {
final String[] k = keyword.split(":");
final Cost blitzCost = new Cost(k[1], false);
final SpellAbility newSA = card.getFirstSpellAbility().copyWithManaCostReplaced(host.getController(), blitzCost);
if (k.length > 2) {
newSA.getMapParams().put("ValidAfterStack", k[2]);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fair enough. It is turning out to be much hairier than anticipated. Would it be better to just shelve this in lieu of a more comprehensive refactor later, then?

It's your choice I guess. I'm not saying you can't try to tackle this and splitting it across consecutive PR is also sometimes easier. Just don't want you getting frustrated by this too quickly ;)

You noticed correctly that spell casting is one of the parts where we're not super accurate in the order according to the CR (sometimes it's also different for AI vs Human PlayerController). I suppose we could have less trouble in that regard if we would basically allow any card to be moved to the stack first as outlined by 601.2a. But then again this might end up too confusing GUI wise, which is one of the reasons we're doing some of these choices in sort of a "look-ahead" fashion. Another one would be this:

601.3a. If an effect prohibits a player from casting a spell with certain qualities, that player may consider any choices to be made during that spell's proposal that may cause those qualities to change. If any such choices could cause that effect to no longer prohibit that player from casting that spell, the player may begin to cast the spell, ignoring the effect.

So yea, that gets messy pretty quickly... 🤹‍♂️

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, geez. It all makes sense now. That clears everything up, thanks @Hanmac.

I saw the comment in getAlternativeCosts that said // TODO with mana value 4 or greater has blitz. and that, coupled with the fact that the Blitz cost was appearing on the list of casting options when clicking a card, led me to believe that the behaviour hadn't been implemented yet. If I'd tried to actually click the Blitz option, I would have seen that it didn't work for mana values < 4.

It didn't help that I didn't know about rule 202.3e, either (cheers, @tool4ever!).

Thanks for your patience, @Hanmac. I've been very obtuse.

So, to summarise:

  • On master, static abilities that grant alternative costs work for normal cards and both faces of MDFCs in the hand, but not when able to cast from the top of the library or the command zone, etc.
  • On the fork, static abilities that grant alternative costs work for normal cards and the front face of MDFCs for all zones they could normally be cast from, but not the back face of MDFCs, and it also breaks Toolbox for X costs (sorry about that 😩 )

I thought that moving the static ability checking code back to where it came from and duplicating the loop over source.mayPlay(activator) would do the trick for the back face of MDFCs, but it doesn't seem to have worked. I'd prefer to find a way to enable the extra CardPlayOptions that minimises disruption, but I'll need to do some more investigation.

Thanks again, both of you, for taking the time to educate me on the codebase!

Copy link
Author

@tchataway tchataway Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's your choice I guess. I'm not saying you can't try to tackle this and splitting it across consecutive PR is also sometimes easier. Just don't want you getting frustrated by this too quickly ;)

Oh I'm happy to keep thrashing away at this. I've been a software engineer for over a decade now so I'm no stranger to frustration 😆

Thanks for the warm welcome. I'm looking forward to learning more about how Forge does things. I might take a look at some of the beginner friendly issues to get a bit more orientation, as well.

So yea, that gets messy pretty quickly... 🤹‍♂️

Yes, absolutely. Those two rules, if implemented faithfully, would turn Forge into a Christopher Nolan film. 😬

S:Mode$ ReduceCost | ValidCard$ Card | ValidSpell$ Spell.Blitz | Activator$ You | Amount$ X | Description$ Blitz costs you pay cost {1} less for each time you've cast your commander from the command zone this game.
SVar:X:Count$TotalCommanderCastFromCommandZone
Oracle:Each creature spell you cast with mana value 4 or greater has blitz. The blitz cost is equal to its mana cost. (You may choose to cast that spell for its blitz cost. If you do, it gains haste and "When this creature dies, draw a card." Sacrifice it at the beginning of the next end step.)\nBlitz costs you pay cost {1} less for each time you've cast your commander from the command zone this game.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/h/hunting_velociraptor.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ ManaCost:2 R
Types:Creature Dinosaur
PT:3/2
K:First Strike
S:Mode$ Continuous | Affected$ Dinosaur.YouCtrl | AddKeyword$ Prowl:2 R | AffectedZone$ Hand | Description$ Dinosaur spells you cast have prowl {2}{R}. (You may cast a spell for its prowl cost if you dealt combat damage to a player this turn with a creature with any of its creature types.)
S:Mode$ Continuous | Affected$ Dinosaur.YouCtrl | AddKeyword$ Prowl:2 R | AffectedZone$ Stack | Description$ Dinosaur spells you cast have prowl {2}{R}. (You may cast a spell for its prowl cost if you dealt combat damage to a player this turn with a creature with any of its creature types.)
DeckHints:Type$Dinosaur
Oracle:First strike\nDinosaur spells you cast have prowl {2}{R}. (You may cast a spell for its prowl cost if you dealt combat damage to a player this turn with a creature with any of its creature types.)