Rework AsThough handling to allow choosing/affecting a specific alternate cast (#11114)

* Rework AsThoughEffect

* some cleanup of MageIdentifer

* refactor ActivationStatus

* fix bolas's citadel

* fix a couple of the Alternative Cost being applied too broadly.

* fix Risen Executioneer

* allow cancellation of AsThough choice.

* fix One with the Multiverse

* cleanup cards needing their own MageIdentifier

* last couple of fixes

* apply reviews for cleaner code.

* some more cleanup
This commit is contained in:
Susucre 2023-10-03 00:42:54 +02:00 committed by GitHub
parent ba135abc78
commit 7c454fb24c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 1176 additions and 395 deletions

View file

@ -163,9 +163,12 @@ public abstract class PlayerImpl implements Player, Serializable {
// indicates that the spell with the set sourceId can be cast with an alternate mana costs (can also be no mana costs)
// support multiple cards with alternative mana cost
protected Set<UUID> castSourceIdWithAlternateMana = new HashSet<>();
protected Map<UUID, ManaCosts<ManaCost>> castSourceIdManaCosts = new HashMap<>();
protected Map<UUID, Costs<Cost>> castSourceIdCosts = new HashMap<>();
//
// A card may be able to cast multiple way with multiple methods.
// The specific MageIdentifier should be checked, before checking null as a fallback.
protected Map<UUID, Set<MageIdentifier>> castSourceIdWithAlternateMana = new HashMap<>();
protected Map<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> castSourceIdManaCosts = new HashMap<>();
protected Map<UUID, Map<MageIdentifier, Costs<Cost>>> castSourceIdCosts = new HashMap<>();
// indicates that the player is in mana payment phase
protected boolean payManaMode = false;
@ -279,13 +282,22 @@ public abstract class PlayerImpl implements Player, Serializable {
this.bufferTimeLeft = player.getBufferTimeLeft();
this.reachedNextTurnAfterLeaving = player.reachedNextTurnAfterLeaving;
this.castSourceIdWithAlternateMana.addAll(player.getCastSourceIdWithAlternateMana());
for (Entry<UUID, ManaCosts<ManaCost>> entry : player.getCastSourceIdManaCosts().entrySet()) {
this.castSourceIdManaCosts.put(entry.getKey(), (entry.getValue() == null ? null : entry.getValue().copy()));
for (Entry<UUID, Set<MageIdentifier>> entry : player.getCastSourceIdWithAlternateMana().entrySet()) {
this.castSourceIdWithAlternateMana.put(entry.getKey(), (entry.getValue() == null ? null : new HashSet<>(entry.getValue())));
}
for (Entry<UUID, Costs<Cost>> entry : player.getCastSourceIdCosts().entrySet()) {
this.castSourceIdCosts.put(entry.getKey(), (entry.getValue() == null ? null : entry.getValue().copy()));
for (Entry<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> entry : player.getCastSourceIdManaCosts().entrySet()) {
this.castSourceIdManaCosts.put(entry.getKey(), new HashMap<>());
for(Entry<MageIdentifier, ManaCosts<ManaCost>> subEntry : entry.getValue().entrySet()) {
this.castSourceIdManaCosts.get(entry.getKey()).put(subEntry.getKey(), subEntry.getValue() == null ? null : subEntry.getValue().copy());
}
}
for (Entry<UUID, Map<MageIdentifier, Costs<Cost>>> entry : player.getCastSourceIdCosts().entrySet()) {
this.castSourceIdCosts.put(entry.getKey(), new HashMap<>());
for(Entry<MageIdentifier, Costs<Cost>> subEntry : entry.getValue().entrySet()) {
this.castSourceIdCosts.get(entry.getKey()).put(subEntry.getKey(), subEntry.getValue() == null ? null : subEntry.getValue().copy());
}
}
this.payManaMode = player.payManaMode;
this.phyrexianColors = player.getPhyrexianColors() != null ? player.phyrexianColors.copy() : null;
for (Designation object : player.designations) {
@ -364,13 +376,20 @@ public abstract class PlayerImpl implements Player, Serializable {
this.reachedNextTurnAfterLeaving = player.hasReachedNextTurnAfterLeaving();
this.clearCastSourceIdManaCosts();
this.castSourceIdWithAlternateMana.clear();
this.castSourceIdWithAlternateMana.addAll(player.getCastSourceIdWithAlternateMana());
for (Entry<UUID, ManaCosts<ManaCost>> entry : player.getCastSourceIdManaCosts().entrySet()) {
this.castSourceIdManaCosts.put(entry.getKey(), (entry.getValue() == null ? null : entry.getValue().copy()));
for (Entry<UUID, Set<MageIdentifier>> entry : player.getCastSourceIdWithAlternateMana().entrySet()) {
this.castSourceIdWithAlternateMana.put(entry.getKey(), (entry.getValue() == null ? null : new HashSet<>(entry.getValue())));
}
for (Entry<UUID, Costs<Cost>> entry : player.getCastSourceIdCosts().entrySet()) {
this.castSourceIdCosts.put(entry.getKey(), (entry.getValue() == null ? null : entry.getValue().copy()));
for (Entry<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> entry : player.getCastSourceIdManaCosts().entrySet()) {
this.castSourceIdManaCosts.put(entry.getKey(), new HashMap<>());
for(Entry<MageIdentifier, ManaCosts<ManaCost>> subEntry : entry.getValue().entrySet()) {
this.castSourceIdManaCosts.get(entry.getKey()).put(subEntry.getKey(), subEntry.getValue() == null ? null : subEntry.getValue().copy());
}
}
for (Entry<UUID, Map<MageIdentifier, Costs<Cost>>> entry : player.getCastSourceIdCosts().entrySet()) {
this.castSourceIdCosts.put(entry.getKey(), new HashMap<>());
for(Entry<MageIdentifier, Costs<Cost>> subEntry : entry.getValue().entrySet()) {
this.castSourceIdCosts.get(entry.getKey()).put(subEntry.getKey(), subEntry.getValue() == null ? null : subEntry.getValue().copy());
}
}
this.phyrexianColors = player.getPhyrexianColors() != null ? player.getPhyrexianColors().copy() : null;
@ -636,13 +655,13 @@ public abstract class PlayerImpl implements Player, Serializable {
}
if (source != null) {
if (abilities.containsKey(ShroudAbility.getInstance().getId())
&& null == game.getContinuousEffects().asThough(this.getId(), AsThoughEffectType.SHROUD, null, sourceControllerId, game)) {
&& game.getContinuousEffects().asThough(this.getId(), AsThoughEffectType.SHROUD, null, sourceControllerId, game).isEmpty()) {
return false;
}
if (sourceControllerId != null
&& this.hasOpponent(sourceControllerId, game)
&& null == game.getContinuousEffects().asThough(this.getId(), AsThoughEffectType.HEXPROOF, null, sourceControllerId, game)
&& game.getContinuousEffects().asThough(this.getId(), AsThoughEffectType.HEXPROOF, null, sourceControllerId, game).isEmpty()
&& abilities.stream()
.filter(HexproofBaseAbility.class::isInstance)
.map(HexproofBaseAbility.class::cast)
@ -1080,25 +1099,37 @@ public abstract class PlayerImpl implements Player, Serializable {
}
@Override
public void setCastSourceIdWithAlternateMana(UUID sourceId, ManaCosts<ManaCost> manaCosts, Costs<Cost> costs) {
public void setCastSourceIdWithAlternateMana(UUID sourceId, ManaCosts<ManaCost> manaCosts, Costs<Cost> costs, MageIdentifier identifier) {
// cost must be copied for data consistence between game simulations
castSourceIdWithAlternateMana.add(sourceId);
castSourceIdManaCosts.put(sourceId, manaCosts != null ? manaCosts.copy() : null);
castSourceIdCosts.put(sourceId, costs != null ? costs.copy() : null);
castSourceIdWithAlternateMana
.computeIfAbsent(sourceId, k -> new HashSet<>())
.add(identifier);
castSourceIdManaCosts
.computeIfAbsent(sourceId, k -> new HashMap<>())
.put(identifier, manaCosts != null ? manaCosts.copy() : null);
castSourceIdCosts
.computeIfAbsent(sourceId, k -> new HashMap<>())
.put(identifier, costs != null ? costs.copy() : null);
if (identifier == null) {
boolean a = true;
}
}
@Override
public Set<UUID> getCastSourceIdWithAlternateMana() {
public Map<UUID, Set<MageIdentifier>> getCastSourceIdWithAlternateMana() {
return castSourceIdWithAlternateMana;
}
@Override
public Map<UUID, Costs<Cost>> getCastSourceIdCosts() {
public Map<UUID, Map<MageIdentifier, Costs<Cost>>> getCastSourceIdCosts() {
return castSourceIdCosts;
}
@Override
public Map<UUID, ManaCosts<ManaCost>> getCastSourceIdManaCosts() {
public Map<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> getCastSourceIdManaCosts() {
return castSourceIdManaCosts;
}
@ -1187,10 +1218,19 @@ public abstract class PlayerImpl implements Player, Serializable {
// ALTERNATIVE COST from dynamic effects
// some effects set sourceId to cast without paying mana costs or other costs
if (getCastSourceIdWithAlternateMana().contains(ability.getSourceId())) {
MageIdentifier identifier = approvingObject == null
? MageIdentifier.Default
: approvingObject.getApprovingAbility().getIdentifier();
if (!getCastSourceIdWithAlternateMana().getOrDefault(ability.getSourceId(), Collections.emptySet()).contains(identifier)) {
// identifier has no alternate cast entry for that sourceId, using Default instead.
identifier = MageIdentifier.Default;
}
if (getCastSourceIdWithAlternateMana().getOrDefault(ability.getSourceId(), Collections.emptySet()).contains(identifier)) {
Ability spellAbility = spell.getSpellAbility();
ManaCosts alternateCosts = getCastSourceIdManaCosts().get(ability.getSourceId());
Costs<Cost> costs = getCastSourceIdCosts().get(ability.getSourceId());
ManaCosts alternateCosts = getCastSourceIdManaCosts().get(ability.getSourceId()).get(identifier);
Costs<Cost> costs = getCastSourceIdCosts().get(ability.getSourceId()).get(identifier);
if (alternateCosts == null) {
noMana = true;
} else {
@ -1273,21 +1313,30 @@ public abstract class PlayerImpl implements Player, Serializable {
}
}
ApprovingObjectResult approvingResult = chooseApprovingObject(
game,
activationStatus.getApprovingObjects().stream().collect(Collectors.toList()),
false
);
if (approvingResult.status.equals(ApprovingObjectResultStatus.NOT_REQUIRED_NO_CHOICE)) {
return false; // canceled choice of approving object.
}
//20091005 - 305.1
if (!game.replaceEvent(GameEvent.getEvent(GameEvent.EventType.PLAY_LAND,
card.getId(), playLandAbility, playerId, activationStatus.getApprovingObject()))) {
card.getId(), playLandAbility, playerId, approvingResult.approvingObject))) {
// int bookmark = game.bookmarkState();
// land events must return original zone (uses for commander watcher)
Zone cardZoneBefore = game.getState().getZone(card.getId());
GameEvent landEventBefore = GameEvent.getEvent(GameEvent.EventType.PLAY_LAND,
card.getId(), playLandAbility, playerId, activationStatus.getApprovingObject());
card.getId(), playLandAbility, playerId, approvingResult.approvingObject);
landEventBefore.setZone(cardZoneBefore);
game.fireEvent(landEventBefore);
if (moveCards(card, Zone.BATTLEFIELD, playLandAbility, game, false, false, false, null)) {
incrementLandsPlayed();
GameEvent landEventAfter = GameEvent.getEvent(GameEvent.EventType.LAND_PLAYED,
card.getId(), playLandAbility, playerId, activationStatus.getApprovingObject());
card.getId(), playLandAbility, playerId, approvingResult.approvingObject);
landEventAfter.setZone(cardZoneBefore);
game.fireEvent(landEventAfter);
@ -1311,6 +1360,68 @@ public abstract class PlayerImpl implements Player, Serializable {
return true;
}
private enum ApprovingObjectResultStatus {
CHOSEN,
NO_POSSIBLE_CHOICE,
NOT_REQUIRED_NO_CHOICE,
}
private class ApprovingObjectResult {
public final ApprovingObjectResultStatus status;
public final ApprovingObject approvingObject; // not null iff status is CHOSEN
private ApprovingObjectResult(ApprovingObjectResultStatus status, ApprovingObject approvingObject) {
this.status = status;
this.approvingObject = approvingObject;
}
}
private ApprovingObjectResult chooseApprovingObject(Game game, List<ApprovingObject> possibleApprovingObjects, boolean required) {
// Choosing
if (possibleApprovingObjects.isEmpty()) {
return new ApprovingObjectResult(ApprovingObjectResultStatus.NO_POSSIBLE_CHOICE, null);
} else {
// Select the ability that you use to permit the action
Map<String, String> keyChoices = new HashMap<>();
int i = 0;
for (ApprovingObject possibleApprovingObject : possibleApprovingObjects) {
MageObject mageObject = game.getObject(possibleApprovingObject.getApprovingAbility().getSourceId());
String choiceValue = "";
MageIdentifier identifier = possibleApprovingObject.getApprovingAbility().getIdentifier();
if (!identifier.getAdditionalText().isEmpty()) {
choiceValue += identifier.getAdditionalText() + ": ";
}
if (mageObject == null) {
choiceValue += possibleApprovingObject.getApprovingAbility().getRule();
} else {
choiceValue += mageObject.getIdName() + ": ";
String moreDetails = possibleApprovingObject.getApprovingAbility().getRule(mageObject.getName());
choiceValue += moreDetails.isEmpty() ? "Cast normally" : moreDetails;
}
keyChoices.put((i++) + "", choiceValue);
}
int choice = 0;
if (!game.inCheckPlayableState() && keyChoices.size() > 1) {
Choice choicePermitting = new ChoiceImpl(required);
choicePermitting.setMessage("Choose the permitting object");
choicePermitting.setKeyChoices(keyChoices);
if (canRespond()) {
if (choose(Outcome.Neutral, choicePermitting, game)) {
String choiceKey = choicePermitting.getChoiceKey();
if (choiceKey != null) {
choice = Integer.parseInt(choiceKey);
}
} else {
return new ApprovingObjectResult(ApprovingObjectResultStatus.NOT_REQUIRED_NO_CHOICE, null);
}
}
}
return new ApprovingObjectResult(ApprovingObjectResultStatus.CHOSEN, possibleApprovingObjects.get(choice));
}
}
protected boolean playManaAbility(ActivatedManaAbilityImpl ability, Game game) {
if (!game.replaceEvent(GameEvent.getEvent(GameEvent.EventType.ACTIVATE_ABILITY,
ability.getId(), ability, playerId))) {
@ -1463,7 +1574,16 @@ public abstract class PlayerImpl implements Player, Serializable {
result = playManaAbility((ActivatedManaAbilityImpl) ability.copy(), game);
break;
case SPELL:
result = cast((SpellAbility) ability, game, false, activationStatus.getApprovingObject());
ApprovingObjectResult approvingResult = chooseApprovingObject(
game,
activationStatus.getApprovingObjects().stream().collect(Collectors.toList()),
false
);
if (approvingResult.status.equals(ApprovingObjectResultStatus.NOT_REQUIRED_NO_CHOICE)) {
return false; // chosen to not approve any AsThough.
}
result = cast((SpellAbility) ability, game, false, approvingResult.approvingObject);
break;
default:
result = playAbility(ability.copy(), game);
@ -3452,9 +3572,9 @@ public abstract class PlayerImpl implements Player, Serializable {
}
// ALTERNATIVE COST FROM dynamic effects
if (getCastSourceIdWithAlternateMana().contains(copy.getSourceId())) {
ManaCosts alternateCosts = getCastSourceIdManaCosts().get(copy.getSourceId());
Costs<Cost> costs = getCastSourceIdCosts().get(copy.getSourceId());
for(MageIdentifier identifier : getCastSourceIdWithAlternateMana().getOrDefault(copy.getSourceId(), new HashSet<>())) {
ManaCosts alternateCosts = getCastSourceIdManaCosts().get(copy.getSourceId()).get(identifier);
Costs<Cost> costs = getCastSourceIdCosts().get(copy.getSourceId()).get(identifier);
boolean canPutToPlay = true;
if (alternateCosts != null && !alternateCosts.canPay(copy, copy, playerId, game)) {
@ -3499,9 +3619,7 @@ public abstract class PlayerImpl implements Player, Serializable {
}
// Get the ability, if any, which allows for spending many as if it were another color.
// TODO: This needs to be improved to handle multiple approving objects.
// See https://github.com/magefree/mage/issues/8584
ApprovingObject approvingObject = game.getContinuousEffects().asThough(ability.getSourceId(),
Set<ApprovingObject> approvingObjects = game.getContinuousEffects().asThough(ability.getSourceId(),
AsThoughEffectType.SPEND_OTHER_MANA, ability, ability.getControllerId(), game);
for (Mana mana : abilityOptions) {
if (mana.count() == 0) {
@ -3517,7 +3635,7 @@ public abstract class PlayerImpl implements Player, Serializable {
// TODO: Describe this
// Abilities that let us spend mana as if it were any (or other colors/types) must be handled separately
// and can't be incorporated into calculating availableMana since the number of combinations would explode.
if (approvingObject != null && mana.count() <= avail.count()) {
if (!approvingObjects.isEmpty() && mana.count() <= avail.count()) {
// TODO: I think this is wrong for spell that require colorless
return true;
}
@ -3764,7 +3882,7 @@ public abstract class PlayerImpl implements Player, Serializable {
// So make it available all the time
boolean canUse;
if (ability instanceof MorphAbility && object instanceof Card && (game.canPlaySorcery(getId())
|| (null != game.getContinuousEffects().asThough(object.getId(), AsThoughEffectType.CAST_AS_INSTANT, playAbility, this.getId(), game)))) {
|| (!game.getContinuousEffects().asThough(object.getId(), AsThoughEffectType.CAST_AS_INSTANT, playAbility, this.getId(), game).isEmpty()))) {
canUse = canPlayCardByAlternateCost((Card) object, availableMana, playAbility, game);
} else {
canUse = canPlay(playAbility, availableMana, object, game); // canPlay already checks alternative source costs and all conditions
@ -3843,23 +3961,23 @@ public abstract class PlayerImpl implements Player, Serializable {
continue;
}
ApprovingObject approvingObject;
Set<ApprovingObject> approvingObjects;
if ((isPlaySpell || isPlayLand) && (fromZone != Zone.BATTLEFIELD)) {
// play hand from non hand zone (except battlefield - you can't play already played permanents)
approvingObject = game.getContinuousEffects().asThough(object.getId(),
approvingObjects = game.getContinuousEffects().asThough(object.getId(),
AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, ability, this.getId(), game);
if (approvingObject == null && isPlaySpell
if (approvingObjects.isEmpty() && isPlaySpell
&& ((SpellAbility) ability).getSpellAbilityType().equals(SpellAbilityType.ADVENTURE_SPELL)) {
approvingObject = game.getContinuousEffects().asThough(object.getId(),
approvingObjects = game.getContinuousEffects().asThough(object.getId(),
AsThoughEffectType.CAST_ADVENTURE_FROM_NOT_OWN_HAND_ZONE, ability, this.getId(), game);
}
} else {
// other abilities from direct zones
approvingObject = null;
approvingObjects = new HashSet<>();
}
boolean canActivateAsHandZone = approvingObject != null
boolean canActivateAsHandZone = !approvingObjects.isEmpty()
|| (fromZone == Zone.GRAVEYARD && canPlayCardsFromGraveyard());
boolean possibleToPlay = canActivateAsHandZone
&& ability.getZone().match(Zone.HAND)
@ -4434,8 +4552,8 @@ public abstract class PlayerImpl implements Player, Serializable {
@Override
public boolean lookAtFaceDownCard(Card card, Game game, int abilitiesToActivate) {
if (null != game.getContinuousEffects().asThough(card.getId(),
AsThoughEffectType.LOOK_AT_FACE_DOWN, null, this.getId(), game)) {
if (!game.getContinuousEffects().asThough(card.getId(),
AsThoughEffectType.LOOK_AT_FACE_DOWN, null, this.getId(), game).isEmpty()) {
// two modes: look at the card or do not look and activate other abilities
String lookMessage = "Look at " + card.getIdName();
String lookYes = "Yes, look at the card";