[MKM] Implement Cases (#11713)

* Implementing "case" mechanic

* [MKM] Implement Case of the Burning Masks

* [MKM] Implement Case of the Filched Falcon

* [MKM] Implement Case of the Crimson Pulse

* [MKM] Implement Case of the Locked Hothouse

* Address PR comments

* some minor adjustments

* adjustments to hints

---------

Co-authored-by: Matthew Wilson <matthew_w@vaadin.com>
Co-authored-by: xenohedron <xenohedron@users.noreply.github.com>
This commit is contained in:
Matthew Wilson 2024-01-29 06:41:23 +02:00 committed by GitHub
parent 25a08c736f
commit f8d15cd6ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 941 additions and 22 deletions

View file

@ -370,7 +370,7 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
}
public static boolean isInUseableZoneDiesTrigger(TriggeredAbility source, GameEvent event, Game game) {
// Get the source permanent of the ability
// Get the source permanent of the ability
MageObject sourceObject = null;
if (game.getState().getZone(source.getSourceId()) == Zone.BATTLEFIELD) {
sourceObject = game.getPermanent(source.getSourceId());

View file

@ -0,0 +1,166 @@
package mage.abilities.common;
import mage.abilities.Ability;
import mage.abilities.condition.CompoundCondition;
import mage.abilities.condition.Condition;
import mage.abilities.condition.common.SolvedSourceCondition;
import mage.abilities.decorator.ConditionalActivatedAbility;
import mage.abilities.decorator.ConditionalAsThoughEffect;
import mage.abilities.decorator.ConditionalContinuousEffect;
import mage.abilities.decorator.ConditionalTriggeredAbility;
import mage.abilities.effects.Effect;
import mage.abilities.effects.OneShotEffect;
import mage.constants.Outcome;
import mage.constants.TargetController;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.util.CardUtil;
/**
* The Case mechanic was added in Murders at Karlov Manor [MKM].
* <ul>
* <li>Each Case has two special keyword abilities: to solve and solved.</li>
* <li>"To Solve — [condition]" means "At the beginning of your end step,
* if [condition] and this Case is not solved, it becomes solved."</li>
* <li>The meaning of "solved" differs based on what type of ability follows it.
* "Solved — [activated ability]" means "[Activated ability].
* Activate only if this Case is solved." Activated abilities contain a colon.
* They're generally written "[Cost]: [Effect]."</li>
* <li>"Solved — [Triggered ability]" means "[Triggered ability].
* This ability triggers only if this Case is solved."
* Triggered abilities use the word "when," "whenever," or "at."
* They're often written as "[Trigger condition], [effect]."</li>
* <li>"Solved — [static ability]" means "As long as this Case is solved, [static ability]."
* Static abilities are written as statements, such as "Creatures you control get +1/+1"
* or "Instant and sorcery spells you cast cost {1} less to cast."</li>
* <li>"To solve" abilities will check for their condition twice:
* once when the ability would trigger, and once when it resolves.
* If the condition isn't true at the beginning of your end step,
* the ability won't trigger at all.
* If the condition isn't true when the ability resolves, the Case won't become solved.</li>
* <li>Once a Case becomes solved, it stays solved until it leaves the battlefield.</li>
* <li>Cases don't lose their other abilities when they become solved.</li>
* <li>Being solved is not part of a permanent's copiable values.
* A permanent that becomes a copy of a solved Case is not solved.
* A solved Case that somehow becomes a copy of a different Case stays solved.</li>
* </ul>
*
* @author DominionSpy
*/
public class CaseAbility extends SimpleStaticAbility {
/**
* Constructs a Case with three abilities:
* <ul>
* <li>A initial ability the Case has at all times</li>
* <li>A "To solve" ability that will conditionally solve the Case
* at the beginning of the controller's end step</li>
* <li>A "Solved" ability the Case has when solved</li>
* </ul>
* The "Solved" ability must be one of the following:
* <ul>
* <li>{@link ConditionalActivatedAbility} using the condition {@link SolvedSourceCondition}.SOLVED</li>
* <li>{@link ConditionalTriggeredAbility} using the condition {@link SolvedSourceCondition}.SOLVED</li>
* <li>{@link SimpleStaticAbility} with only {@link ConditionalAsThoughEffect} or {@link ConditionalContinuousEffect} effects</li>
* </ul>
*
* @param initialAbility The ability that a Case has at all times
* @param toSolveCondition The condition to be checked when solving
* @param solvedAbility The ability that a solved Case has
*/
public CaseAbility(Ability initialAbility, Condition toSolveCondition, Ability solvedAbility) {
super(Zone.ALL, null);
if (initialAbility instanceof EntersBattlefieldTriggeredAbility) {
((EntersBattlefieldTriggeredAbility) initialAbility).setTriggerPhrase("When this Case enters the battlefield, ");
}
addSubAbility(initialAbility);
addSubAbility(new CaseSolveAbility(toSolveCondition));
if (solvedAbility instanceof ConditionalActivatedAbility) {
((ConditionalActivatedAbility) solvedAbility).hideCondition();
} else if (!(solvedAbility instanceof ConditionalTriggeredAbility)) {
if (solvedAbility instanceof SimpleStaticAbility) {
for (Effect effect : solvedAbility.getEffects()) {
if (!(effect instanceof ConditionalContinuousEffect ||
effect instanceof ConditionalAsThoughEffect)) {
throw new IllegalArgumentException("solvedAbility must be one of ConditionalActivatedAbility, " +
"ConditionalTriggeredAbility, or StaticAbility with conditional effects.");
}
}
} else {
throw new IllegalArgumentException("solvedAbility must be one of ConditionalActivatedAbility, " +
"ConditionalTriggeredAbility, or StaticAbility with conditional effects.");
}
}
addSubAbility(solvedAbility.withFlavorWord("Solved")); // TODO: Technically this shouldn't be italicized
}
protected CaseAbility(final CaseAbility ability) {
super(ability);
}
@Override
public CaseAbility copy() {
return new CaseAbility(this);
}
}
class CaseSolveAbility extends BeginningOfEndStepTriggeredAbility {
CaseSolveAbility(Condition condition) {
super(new SolveEffect(), TargetController.YOU,
new CompoundCondition(condition, SolvedSourceCondition.UNSOLVED), false);
withFlavorWord("To solve"); // TODO: technically this shouldn't be italicized
setTriggerPhrase(CardUtil.getTextWithFirstCharUpperCase(trimIf(condition.toString())));
}
private CaseSolveAbility(final CaseSolveAbility ability) {
super(ability);
}
@Override
public CaseSolveAbility copy() {
return new CaseSolveAbility(this);
}
@Override
public String getRule() {
return super.getRule() + ". <i>(If unsolved, solve at the beginning of your end step.)</i>";
}
private static String trimIf(String text) {
if (text.startsWith("if ")) {
return text.substring(3);
}
return text;
}
}
class SolveEffect extends OneShotEffect {
SolveEffect() {
super(Outcome.Benefit);
}
private SolveEffect(final SolveEffect effect) {
super(effect);
}
@Override
public SolveEffect copy() {
return new SolveEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
if (permanent == null || permanent.isSolved()) {
return false;
}
return permanent.solve(game, source);
}
}

View file

@ -0,0 +1,32 @@
package mage.abilities.condition.common;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.game.Game;
import mage.game.permanent.Permanent;
/**
* Checks if a Permanent is solved
*
* @author DominionSpy
*/
public enum SolvedSourceCondition implements Condition {
SOLVED(true),
UNSOLVED(false);
private final boolean solved;
SolvedSourceCondition(boolean solved) {
this.solved = solved;
}
@Override
public boolean apply(Game game, Ability source) {
Permanent permanent = game.getPermanent(source.getSourceId());
return permanent != null && permanent.isSolved() == solved;
}
@Override
public String toString() {
return "{this} is " + (solved ? "solved" : "unsolved");
}
}

View file

@ -18,6 +18,7 @@ public class ConditionalActivatedAbility extends ActivatedAbilityImpl {
private static final Effects emptyEffects = new Effects();
private String ruleText = null;
private boolean showCondition = true;
public ConditionalActivatedAbility(Effect effect, Cost cost, Condition condition) {
this(Zone.BATTLEFIELD, effect, cost, condition);
@ -36,6 +37,7 @@ public class ConditionalActivatedAbility extends ActivatedAbilityImpl {
protected ConditionalActivatedAbility(final ConditionalActivatedAbility ability) {
super(ability);
this.ruleText = ability.ruleText;
this.showCondition = ability.showCondition;
}
@Override
@ -51,22 +53,29 @@ public class ConditionalActivatedAbility extends ActivatedAbilityImpl {
return new ConditionalActivatedAbility(this);
}
public ConditionalActivatedAbility hideCondition() {
this.showCondition = false;
return this;
}
@Override
public String getRule() {
if (ruleText != null && !ruleText.isEmpty()) {
return ruleText;
}
StringBuilder sb = new StringBuilder(super.getRule());
sb.append(" Activate only ");
if (timing == TimingRule.SORCERY) {
sb.append("as a sorcery and only ");
if (showCondition) {
sb.append(" Activate only ");
if (timing == TimingRule.SORCERY) {
sb.append("as a sorcery and only ");
}
String conditionText = condition.toString();
if (!conditionText.startsWith("during") && !conditionText.startsWith("before") && !conditionText.startsWith("if")) {
sb.append("if ");
}
sb.append(conditionText);
sb.append('.');
}
String conditionText = condition.toString();
if (!conditionText.startsWith("during") && !conditionText.startsWith("before") && !conditionText.startsWith("if")) {
sb.append("if ");
}
sb.append(conditionText);
sb.append('.');
return sb.toString();
}
}

View file

@ -1,5 +1,6 @@
package mage.abilities.decorator;
import mage.abilities.Ability;
import mage.abilities.Modes;
import mage.abilities.TriggeredAbility;
import mage.abilities.TriggeredAbilityImpl;
@ -108,4 +109,10 @@ public class ConditionalTriggeredAbility extends TriggeredAbilityImpl {
return ability.isOptional();
}
@Override
public Ability withFlavorWord(String flavorWord) {
ability.withFlavorWord(flavorWord);
return this;
}
}

View file

@ -12,12 +12,12 @@ import java.awt.*;
*/
public class ConditionHint implements Hint {
private Condition condition;
private String trueText;
private Color trueColor;
private String falseText;
private Color falseColor;
private Boolean useIcons;
private final Condition condition;
private final String trueText;
private final Color trueColor;
private final String falseText;
private final Color falseColor;
private final boolean useIcons;
public ConditionHint(Condition condition) {
this(condition, condition.toString());
@ -27,7 +27,7 @@ public class ConditionHint implements Hint {
this(condition, textWithIcons, null, textWithIcons, null, true);
}
public ConditionHint(Condition condition, String trueText, Color trueColor, String falseText, Color falseColor, Boolean useIcons) {
public ConditionHint(Condition condition, String trueText, Color trueColor, String falseText, Color falseColor, boolean useIcons) {
this.condition = condition;
this.trueText = CardUtil.getTextWithFirstCharUpperCase(trueText);
this.trueColor = trueColor;
@ -58,7 +58,7 @@ public class ConditionHint implements Hint {
}
@Override
public Hint copy() {
public ConditionHint copy() {
return new ConditionHint(this);
}
}

View file

@ -0,0 +1,50 @@
package mage.abilities.hint.common;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.abilities.condition.common.SolvedSourceCondition;
import mage.abilities.hint.ConditionHint;
import mage.game.Game;
import mage.game.permanent.Permanent;
public class CaseSolvedHint extends ConditionHint {
private final Condition condition;
/**
* Hint for use with CaseAbility
* @param condition Same condition added to CaseAbility
*/
public CaseSolvedHint(Condition condition) {
super(SolvedSourceCondition.SOLVED, "Case is solved.", null, "Case is unsolved.", null, true);
this.condition = condition;
}
protected CaseSolvedHint(final CaseSolvedHint hint) {
super(hint);
this.condition = hint.condition;
}
@Override
public String getText(Game game, Ability ability) {
Permanent permanent = game.getPermanent(ability.getSourceId());
if (permanent == null) {
return "";
}
String text = super.getText(game, ability);
if (!permanent.isSolved()) {
text += " " + getConditionText(game, ability);
if (condition.apply(game, ability) && game.isActivePlayer(ability.getControllerId())) {
text += " Case will be solved at the end step.";
}
}
return text;
}
/**
* Override to add specific information on satisfying the condition.
*/
protected String getConditionText(Game game, Ability ability) {
return "";
}
}

View file

@ -3,7 +3,6 @@ package mage.abilities.hint.common;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.abilities.hint.ConditionHint;
import mage.abilities.hint.Hint;
import mage.game.Game;
import java.awt.*;
@ -23,7 +22,7 @@ public class ConditionPermanentHint extends ConditionHint {
super(condition, textWithIcons);
}
public ConditionPermanentHint(Condition condition, String trueText, Color trueColor, String falseText, Color falseColor, Boolean useIcons) {
public ConditionPermanentHint(Condition condition, String trueText, Color trueColor, String falseText, Color falseColor, boolean useIcons) {
super(condition, trueText, trueColor, falseText, falseColor, useIcons);
}
@ -36,12 +35,11 @@ public class ConditionPermanentHint extends ConditionHint {
if (game.getPermanent(ability.getSourceId()) == null) {
return "";
}
return super.getText(game, ability);
}
@Override
public Hint copy() {
public ConditionPermanentHint copy() {
return new ConditionPermanentHint(this);
}
}

View file

@ -39,6 +39,7 @@ public enum SubType {
AURA("Aura", SubTypeSet.EnchantmentType),
BACKGROUND("Background", SubTypeSet.EnchantmentType),
CARTOUCHE("Cartouche", SubTypeSet.EnchantmentType),
CASE("Case", SubTypeSet.EnchantmentType),
CLASS("Class", SubTypeSet.EnchantmentType),
CURSE("Curse", SubTypeSet.EnchantmentType),
ROLE("Role", SubTypeSet.EnchantmentType),

View file

@ -567,6 +567,12 @@ public class GameEvent implements Serializable {
playerId the player crafting
*/
EXILED_WHILE_CRAFTING,
/* Solving a Case
targetId the permanent being solved
sourceId of the ability solving
playerId the player solving
*/
SOLVE_CASE, CASE_SOLVED,
/* Become suspected
targetId the permanent being suspected
sourceId of the ability suspecting

View file

@ -446,6 +446,10 @@ public interface Permanent extends Card, Controllable {
void setRingBearer(Game game, boolean value);
boolean isSolved();
boolean solve(Game game, Ability source);
@Override
Permanent copy();

View file

@ -95,6 +95,7 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
// maximal number of creatures the creature can be blocked by 0 = no restriction
protected int maxBlockedBy = 0;
protected boolean deathtouched;
protected boolean solved = false;
protected Map<String, List<UUID>> connectedCards = new HashMap<>();
protected Set<MageObjectReference> dealtDamageByThisTurn;
@ -145,6 +146,7 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
this.blocking = permanent.blocking;
this.maxBlocks = permanent.maxBlocks;
this.deathtouched = permanent.deathtouched;
this.solved = permanent.solved;
this.markedLifelink = permanent.markedLifelink;
this.connectedCards = CardUtil.deepCopyObject(permanent.connectedCards);
this.dealtDamageByThisTurn = CardUtil.deepCopyObject(permanent.dealtDamageByThisTurn);
@ -1913,6 +1915,33 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
return ringBearerFlag;
}
@Override
public boolean isSolved() {
return solved;
}
@Override
public boolean solve(Game game, Ability source) {
if (this.solved) {
return false;
}
GameEvent event = new GameEvent(GameEvent.EventType.SOLVE_CASE, getId(),
source, source.getControllerId());
if (game.replaceEvent(event)) {
return false;
}
Player controller = game.getPlayer(source.getControllerId());
if (controller != null) {
game.informPlayers(controller.getLogName() + " solved " + this.getLogName() +
CardUtil.getSourceLogName(game, source));
}
this.solved = true;
game.fireEvent(new GameEvent(EventType.CASE_SOLVED, getId(), source,
source.getControllerId()));
return true;
}
@Override
public boolean fight(Permanent fightTarget, Ability source, Game game) {
return this.fight(fightTarget, source, game, true);