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?β
- 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).
- 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β
- Compile β review code. The generated
check(state)functions appear under each property:def check(state): return state.lands_in_play() >= 5 - 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
| Output | Meaning |
|---|---|
| Success rate | Games where one single line satisfied all properties at their moments. |
| Per property | Games where each property was satisfiable in any viable line (can be higher than the joint rate). |
| Runs table | One 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 replay | Click 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 tree | Every 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. |
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)
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