GitHub β†—

Worked examples

A complete session walkthrough, how to read the results, and how to implement cards β€” from a basic land to abilities that use the stack.

Example session: β€œcan my deck ramp?”

  1. Import. Paste a Moxfield or MTGTop8 URL and name the session β€” the format (Duel Commander) is detected automatically. The decklist shows every card with its image; cards without a dedicated implementation are shown in red (they still play as a vanilla approximation).
  2. Properties. Two properties:
    • at the end of precombat main of turn 3 β€” β€œat least 5 lands on the battlefield”
    • before end step of turn 3 β€” β€œthe commander is in play”
  3. Compile β†’ review code. The generated check(state) functions appear under each property:
    def check(state):
        return state.lands_in_play() >= 5
  4. Run. 100 games, 5 s timeout per game, 1 mulligan allowed, on the play. A seed is drawn (and displayed) so the run is reproducible; statistics stream live.

Reading the results

OutputMeaning
Success rateGames where one single line satisfied all properties at their moments.
Per propertyGames where each property was satisfiable in any viable line (can be higher than the joint rate).
Runs tableOne row per game: βœ“/βœ— result (⏱ timed out), opening hand on hover, steps of the winning line, states explored/considered, the 🌳 full search tree, and a πŸ› count when any bug was hit (click for the tracebacks).
Board replayClick a successful game to replay its winning line step by step on an MTGO-like board β€” zones, stack, mana pool, counters. Keyboard: ←/β†’ step through frames, ↑/↓ switch to the previous/next successful game.
Search treeEvery state the search created, laid out left-to-right; per-node circles show each property's status (verified / pending / unreachable), the winning line in gold.
Interpreting a low success rate

Check the per-property numbers first. If one property alone scores low, the deck can't do that thing β€” if all score high individually but the joint rate is low, the deck can't do them on the same line. Open a few βœ— games' trees: the red circles show exactly where each line died.

Implementing cards

Every implemented card is one file in src/mtg_goldfish/cards/, auto-discovered at startup. The docstring states the card's oracle behaviour and any solitaire approximation.

A dual land

"""Badlands β€” Land β€” Swamp Mountain. {T}: add {B} or {R}."""
from ..engine.mana import ManaAbility
from .base import Card
from .registry import register


@register
class Badlands(Card):
    card_name = "Badlands"

    def mana_abilities(self, state):
        return [ManaAbility(amount=1, choices=("B", "R"))]

β€œEnters with counters” β€” a replacement effect

Counters a permanent enters with are on it from the moment it enters: nothing goes on the stack, no trigger fires. Use the enters_with_counters hook:

"""Hickory Woodlot β€” Land. Enters tapped with two depletion counters.
{T}, Remove a depletion counter: add {G}{G}; when none remain, sacrifice it."""
@register
class HickoryWoodlot(Card):
    card_name = "Hickory Woodlot"

    def etb_tapped(self, state):
        return True

    def enters_with_counters(self, state):
        return {"depletion": 2}

    def mana_abilities_perm(self, state, perm):
        if perm.counters.get("depletion", 0) <= 0:
            return []
        return [ManaAbility(amount=2, choices=("G",))]

    def on_tap_for_mana(self, state, permanent, color):
        permanent.counters["depletion"] -= 1
        if permanent.counters["depletion"] <= 0:
            state.emit("Hickory Woodlot: no depletion counters β€” sacrifice")
            state.leaves_battlefield(permanent, "graveyard")

A triggered ability

Override the matching hook β€” the engine wraps it into a stack item when the event happens, and the hook body runs when the trigger resolves:

"""Lotus Cobra β€” Landfall: whenever a land you control enters, add one mana."""
@register
class LotusCobra(Card):
    card_name = "Lotus Cobra"

    def on_other_etb(self, state, perm, entering):
        if "land" in entering.type_line.lower():
            state.mana_pool.add("G", 1)   # simplified for the example
            state.emit("Lotus Cobra: landfall β€” add {G}")

An activated ability: pay at activation, effect at resolution

Activated abilities use CardAction.activated(label, pre_fn, resolve_fn). The engine calls pre_fn before the ability goes on the stack β€” it pays every cost and returns True (or False to abort). resolve_fn applies the effect when the ability resolves. Because the game state may have changed between the two, both re-find their objects by uid:

"""Zuran Orb β€” {0}: Sacrifice a land: You gain 2 life."""
@register
class ZuranOrb(Card):
    card_name = "Zuran Orb"

    def battlefield_actions(self, state, perm):
        lands = [p for p in state.battlefield if "land" in p.type_line.lower()]
        if not lands:
            return []
        pick = next((p for p in lands if p.tapped), lands[0])

        def pay(st):                          # at ACTIVATION
            sac = st.find_permanent(pick.uid)
            if sac is None:
                return False
            st.leaves_battlefield(sac, "graveyard")
            st.emit(f"Zuran Orb: sacrifice {sac.name}")
            return True

        def resolve(st):                      # at RESOLUTION
            st.life += 2
            st.emit("Zuran Orb: gain 2 life")

        return [CardAction.activated(
            "Zuran Orb: sacrifice a land β€” gain 2 life",
            pay,
            resolve,
            source_name="Zuran Orb",        # lets the viewer show the card image on the stack
            ability_text="Sacrifice a land: You gain 2 life",
        )]

Choices are branches

A hook that must choose (fetch target, tutor target, discard) never picks inside its effect β€” it either enumerates one CardAction per choice, or returns one cloned state per option from its resolve function so the exhaustive search explores them all:

def on_resolve(self, state):          # e.g. a tutor spell
    targets = state.search_library(lambda c: c.is_creature)
    return branch_over(state, [t.name for t in targets], put_in_hand)
Cards without an implementation

A card with no dedicated file still plays as a vanilla approximation β€” it can be cast/played, permanents enter the battlefield and count toward board state and spell tallies, but its special text is ignored (unimplemented lands tap for one mana of any colour in the commander's identity). Such cards are flagged in red in the decklist, so you know a result that relies on them is approximate. To model one exactly, add a file for it as shown above.

What a winning line looks like

Each step of the replay is one log frame. The stack fills and empties as spells and abilities go on and resolve β€” for example, cracking a fetchland:

1  'play land Bloodstained Mire'                          stack: β€”
2  'Bloodstained Mire: tap, pay 1 life, sacrifice'        stack: β€”            ← cost paid at activation
3  'Bloodstained Mire: fetch Mountain (on the stack)'     stack: [fetch]      ← ability stacked
4  'fetched Mountain β€” shuffle'                           stack: β€”            ← resolves + effect, one step