ETHAN CROOKS

Programming Magic The Gathering - Part 5: Tearing everything down

2020-12-7

A couple weeks ago I sat down and thought about replacement effects. And I realised that how the program is now, they can never work.

Nine Lives
Lava Coil
Ormos, Archive Keeper

Replacement Effects 101

Magic: The Gathering allows cards that can replace effects with different effects. A card can say "If you would gain life, instead draw that many cards", which would cause any lifegain to never happen, with the drawing of cards to happen in it's place. Any abilities that care about you gaining life will never trigger, as the lifegain never happened. It was completely replaced.

This mean that before literally anything happens ever, the game needs to check to see if there is something that replaces it. It also needs to check if something replaces the replacement, or if something replaces THAT replacement, and so on. I need to make the system generic enough to allow for this, which will involve reworking how the game thinks about events and effects.

Currently, a card that does something when it resolves has two attributes: a function that runs to run the effect, and a function that runs to revert the effect.

using effectdef = Action<OID, List<Target>>;
var island = new MTGLib.MTGObject.BaseCardAttributes()
{
    name = "Island",
    cardTypes = new HashSet<MTGObject.CardType> { MTGObject.CardType.Land },
    superTypes = new HashSet<MTGObject.SuperType> { MTGObject.SuperType.Basic },
    subTypes = new HashSet<MTGObject.SubType> { MTGObject.SubType.Island },
    activatedAbilities = new List<ActivatedAbility>
    {
        new ManaAbility(
            new Cost[]
            {
                new CostTapSelf()
            },
            // Add one blue mana to the controller's mana pool
            new effectdef[] {
                (source, targets) => {
                    int controller = MTG.Instance.objects[source].attr.controller;
                    MTG.Instance.players[controller].manaPool.AddMana(
                        ManaSymbol.Blue
                    );
                }
            },
            // When reverting, remove one blue mana from the controller's mana pool
            new effectdef[] {
                (source, targets) => {
                    int controller = MTG.Instance.objects[source].attr.controller;
                    MTG.Instance.players[controller].manaPool.RemoveMana(
                        ManaSymbol.Blue
                    );
                }
            }
        )
    }
};

For replacement effects to work, this can no longer be a simple set of two functions. The system needs to be modular - as tiny parts of abilities can be replaced with the rest left untouched. For example, while still considering the "If you would gain life, instead draw that many cards" effect from earlier, and the following card, Cruel Ultimatum:

Cruel Ultimatum

This card does a lot of stuff, most of which will resolve as normal. However, the "then gain 5 life" right at the end: our replacement effect cares about that part! Instead you'll draw 5 cards.

So instead of each effect being one big dumb lambda, they need to be a list of events - each separate from one another, allowing themselves to be replaced.

Another spanner in the works are "can't" effects - stating an effect can't happen. These prevent the effect from happening at all. Player also can't pay costs if they involve something that "can't happen".

Solemnity
Narset, Parter of Veils
Drannith Magistrate

For example, if Solemnity is in play, if some counters would be placed on a creature, instead nothing happens. Also, neither player can pay costs that involve placing counters on stuff.

As well as this, *deep breath*, there is the matter of reversing effects.

Reversing Effects

Mana abilities need to be reversible, as if a player cancels casting a spell after activating some mana abilities, each one needs to be undone to bring the game back to where it was.

For example, if you begin casting a spell, you can tap some lands while paying for the cost. If you then cancel the cast, the lands need to be untapped and the mana removed.

"If a player takes an illegal action or starts to take an action but can't legally complete it, the entire action is reversed and any payments already made are canceled. No abilities trigger and no effects apply as a result of an undone action." - MTG Rules 723.1. - Handling Illegal Actions

However, things get even more complex. Consider the following (perfectly valid) card:

Weird Mana Rock: {1}{T}: Add {G}. Draw a card.

As this ability produces mana and does not require a target, it is a mana ability and can be activated while casting a spell. However - drawing a card is something that cannot be reverted! In fact:

"Players may not reverse actions that moved cards to a library, moved cards from a library to any zone other than the stack, caused a library to be shuffled, or caused cards from a library to be revealed." - MTG Rules 723.1. - Handling Illegal Actions

So there we go, this ability is a valid mana ability that, unlike most mana abilities, cannot be reverted. If we read some more:

"Each player may also reverse any legal mana abilities that player activated while making the illegal play, unless mana from those abilities or from any triggered mana abilities they caused to trigger was spent on another mana ability that wasn't reversed." - MTG Rules 723.1. - Handling Illegal Actions

So the 1 mana that was spent on this ability will also be irreversible. Which means it's land should stay tapped if the player cancels casting the spell. Ooh boy. If this isn't handled correctly, weird edge cases like this (which the game is full of) will cause my system to fall apart.

Aaaah.

In summary, this game needs a system that allows...

  • ...any effect to be replaced with another.
  • ...the player to choose which replacement effects can apply, if there are multiple affecting the same event.
  • ...any effect to be disallowed from applying.
  • ...costs involving disallowed effects to be unpayable.
  • ...effects to report back whether they were able to apply or not, and to make sure any subsequent effects are affected accordingly.
  • ...some effects to be reversible.
  • ...some effects to very specifically not be reversible.
  • ...tracking of abilities that were used to pay for irreversible abilities.

Anyway, in short - this is why I haven't posted anything in a couple weeks. I've been going mad trying to work out how the hell this is going to work. And I am breaking my silence because I think, I think, I have something that works.

My Implementation

The rules uses the word event to refer to a thing that happens. I've found while making this that making sure my code reflects the rules are closely as possible usually works out well, so I started by making an abstract MTGEvent class (cause "event" is a reserved word in C#):

public abstract class MTGEvent
{
    public OID source { get; protected set; }

    public MTGEvent(OID source) { this.source = source; }

    protected abstract bool ApplyAction();
    protected virtual void RevertAction() { }

    protected abstract bool SelfRevertable { get; }

    public bool Apply()
    {
        return ApplyAction();
    }

    public void Revert()
    {
        if (!Revertable)
            RevertAction();
    }
}

Each event has a required function to run to apply it, and an optional function to run to revert it. Each event has a required source parameter, set to whatever object created the event, or null otherwise. This allows triggered abilities, replacement effects or other events to check the source object to see if they should apply, such as "Protection from {colour}" effects for example.

Gingerbrute
Apostle of Purifying Light
Amareth, the Lustrous

Other variables can be added in subclasses - such as this GainLifeEvent for example.

public class GainLifeEvent : MTGEvent
{
    public readonly int player;
    public readonly int amount;

    public GainLifeEvent(OID source, int player, int amount) : base(source)
    {
        this.player = player;
        this.amount = amount;
    }

    protected override bool SelfRevertable => true;

    protected override bool ApplyAction()
    {
        if (amount <= 0)
            return false;

        MTG.Instance.players[player].ChangeLife(amount);
        return true;
    }

    protected override void RevertAction()
    {
        MTG.Instance.players[player].ChangeLife(-amount);
    }
}

Effects can check how much life is being gained, and who is gaining the life, and from what source the lifegain is from. If an ability needs to only trigger if a specific player gains life, or if more than a certain amount of life was gained, then it can check.

Child Events

While an event is running, it may need to call another event. For example, DrawCardsEvent will need to call into MoveZoneEvent to move the card from a library to a hand. These child events need to be properly tracked, so that they can easily be reverted. Here is the full MTGEvent class as it is now:

public abstract class MTGEvent
{
    protected readonly LinkedList<MTGEvent> children = new LinkedList<MTGEvent>();

    public OID source { get; protected set; }

    public MTGEvent(OID source) { this.source = source; }

    protected abstract bool ApplyAction();
    protected virtual void RevertAction() { }

    // Call into MTG.Instance to push the new event, adding it to the list of children
    // if it succedded
    protected bool PushChild(MTGEvent child)
    {
        var result = MTG.Instance.PushEvent(child);
        if (result)
            children.AddLast(child);
        return result;
    }

    // This can be overriden so stuff like the revertable check
    // can be disabled.
    protected virtual void RevertAllChildren()
    {
        while (children.Last != null)
        {
            children.Last.Value.Revert();
            children.RemoveLast();
        }
    }

    public virtual bool Apply()
    {
        return ApplyAction();
    }

    // Not including children, is the stuff I do revertable?
    protected abstract bool SelfRevertable { get; }

    // Check SelfRevertable of me and my children to see if my event
    // as a whole is revertable.
    protected virtual bool Revertable { get
        {
            if (!SelfRevertable)
                return false;
            foreach (var child in children)
                if (!child.Revertable)
                    return false;
            return true;
        }
    }

    // If I'm revertable, revert me.
    public virtual void Revert()
    {
        if (!Revertable)
        {
            Console.WriteLine($"{GetType().Name} did not revert!");
            return;
        }

        Console.WriteLine($"{GetType().Name} reverted!");
        RevertAction();
        RevertAllChildren();
    }
}

Now when a cast spell is cancelled, it can go back through the events it ran and revert them each, leaving the game back to what it was.

Here's a lovely little debug printout of what events are running during the process of casting a spell, tapping three mountains in the process:

Priority for player 0
Choose what to do with your priority!
[0] - Pass priority
[1] - Cast a spell -> Onakke Ogre
[2] - Cast a spell -> Onakke Ogre
[3] - Cast a spell -> Onakke Ogre
[4] - Cast a spell -> Onakke Ogre
[5] - Cast a spell -> Onakke Ogre
[6] - Activate a mana ability -> Mountain
[7] - Activate a mana ability -> Mountain
[8] - Activate a mana ability -> Mountain
> 4
CastSpellEvent pushed
 : MoveZoneEvent pushed
 : MoveZoneEvent resolved
 : PayManaCostEvent pushed
[Choose which mana to use to pay for {1} - activate mountain]
 :  : ActivateAbilityEvent pushed
 :  :  : TapSelfCostEvent pushed
 :  :  :  : TapEvent pushed
 :  :  :  : TapEvent resolved
 :  :  : TapSelfCostEvent resolved
 :  :  : EffectEvent pushed
 :  :  :  : EventContainerAddMana pushed
 :  :  :  :  : AddManaEvent pushed
 :  :  :  :  : AddManaEvent resolved
 :  :  :  : EventContainerAddMana resolved
 :  :  : EffectEvent resolved
 :  : ActivateAbilityEvent resolved
Choose which mana to use to pay for {1} - use {R}]
 :  : RemoveManaEvent pushed
 :  : RemoveManaEvent resolved
 : PayManaCostEvent resolved
 : PayManaCostEvent pushed
[Choose which mana to use to pay for {1} - activate mountain]
 :  : ActivateAbilityEvent pushed
 :  :  : TapSelfCostEvent pushed
 :  :  :  : TapEvent pushed
 :  :  :  : TapEvent resolved
 :  :  : TapSelfCostEvent resolved
 :  :  : EffectEvent pushed
 :  :  :  : EventContainerAddMana pushed
 :  :  :  :  : AddManaEvent pushed
 :  :  :  :  : AddManaEvent resolved
 :  :  :  : EventContainerAddMana resolved
 :  :  : EffectEvent resolved
 :  : ActivateAbilityEvent resolved
[Choose which mana to use to pay for {1} - use {R}]
 :  : RemoveManaEvent pushed
 :  : RemoveManaEvent resolved
 : PayManaCostEvent resolved
 : PayManaCostEvent pushed
[Choose which mana to use to pay for {1} - activate mountain]
 :  : ActivateAbilityEvent pushed
 :  :  : TapSelfCostEvent pushed
 :  :  :  : TapEvent pushed
 :  :  :  : TapEvent resolved
 :  :  : TapSelfCostEvent resolved
 :  :  : EffectEvent pushed
 :  :  :  : EventContainerAddMana pushed
 :  :  :  :  : AddManaEvent pushed
 :  :  :  :  : AddManaEvent resolved
 :  :  :  : EventContainerAddMana resolved
 :  :  : EffectEvent resolved
 :  : ActivateAbilityEvent resolved
[Choose which mana to use to pay for {1} - use {R}]
 :  : RemoveManaEvent pushed
 :  : RemoveManaEvent resolved
 : PayManaCostEvent resolved
CastSpellEvent resolved

There's a ton of different events here, each separate and modular so they can be replaced and triggered against. If you want to have a look at where I'm at for events right now, as well as the definitions for all these different events, have a look at the source files here.

That's it for this blog post. I've certainly got enough to talk about for my assessment, so I'll be putting this project on hold for now while I finish off my uni work. I've been really enjoying writing this, so I'll probably pick it back up at the nearest opportunity. Until then, thanks for reading!

View my source code here.