Skip to main content

A python-chess-style API + driver for an MTG rules engine (runs the Datalog/souffle build)

Project description

mtg — a python-chess-style API for an MTG rules engine

One stateful object holds the whole game; you read the legal moves, push one, pop to take it back — the python-chess shape, but with Magic's vocabulary (players, turns, steps, the stack, and the zones — battlefield, hand, library, graveyard, command zone).

Quickstart

Run from the repository root (see the packaging note below).

import mtg

g = mtg.Game()                    # the demo Gruul-vs-Dimir matchup (real cards), seed 0
g.legal_moves                            # the action tuples you may play now
g.push(g.legal_moves[0])                 # make a move; advances to the next decision point
g.pop()                                  # take it back (no snapshots needed — env.step is pure)

g.turn                                   # whose decision it is
g.life()                                 # {'alice': 20, 'bob': 20}
g.battlefield()                          # {permanent: controller}
g.hand('alice')                          # ['grizzly_bears_6', ...]
g.is_game_over(), g.outcome(), g.result()

while not g.is_game_over():              # a trivial "first legal option" policy
    g.push(g.legal_moves[0])

A move is an action tuple from the referee (env.legal_actions):

tuple meaning
("cast", player, spell, {choices}) cast a spell with its forced sub-choices (mode/target/name)
("cast_commander", player, name) cast the commander from the command zone (§903.6)
("activate", player, ability_row, {...}) activate an ability
("attack", frozenset(attackers)) declare attackers
("block", frozenset((blocker, attacker))) declare blockers
("pass",) pass priority / end the window

Game.describe_move(move) renders any of these to a short label.

Keys for tree search

cast/activate moves carry a {choices} dict, so the raw tuple is not hashable. For tree modeling:

mtg.Game.move_key(move)   # hashable canonical move (key a policy / visited table on this)
g.key()                          # hashable transposition key of the POSITION (driver._facts_key)
g.copy()                         # cheap, independent branch — push/pop without touching the parent
g.push(move, checked=False)      # skip the legality re-check in hot loops

with g.branch(move):             # push on enter, pop on exit — for recursive tree walks
    visit(g.key())               # g is the child here; restored to the parent on exit (even on exception)

g.key() keys a transposition table / repetition set directly; equal keys denote engine-equivalent states. branch() is pure push/pop ergonomics — it does not use the engine's incremental rollback (measured ~1.0× for this sorcery-speed move space; the tree is too narrow to amortize — see incremental/README.md).

Inspecting cards (the piece_at analog)

Zone accessors return opaque ids; card()/permanents() return their derived characteristics:

g.card("grizzly_bears_6")
# {'id':..., 'zone':'battlefield', 'controller':'alice', 'types':['creature'], 'subtypes':['bear'],
#  'colors':['green'], 'is_creature':True, 'power':2, 'toughness':2, 'keywords':[],
#  'tapped':False, 'summoning_sick':False}

g.permanents()                       # views for everything on the battlefield (one engine eval for all)
g.permanents(player="alice")         # filter by controller
g.permanents(type="creature")        # filter by printed type

Power/toughness/creature-ness/keywords/control are the engine's derived values (effects applied); types/colors/subtypes are printed. Fields that don't apply (controller off the battlefield, P/T of a land) come back None/[]/False.

Serialization (the FEN analog)

Persist a position to JSON and reconstruct an engine-equivalent game — future play included, since the RNG position is preserved:

blob = g.serialize()                 # JSON str (round-trips sets/tuples/dicts/RNG that JSON can't hold)
g2 = mtg.Game.deserialize(blob)
assert g2.key() == g.key()           # same position; replaying the same moves yields identical games

g3 = mtg.Game.from_state(other_game.state)   # wrap a raw state dict in-memory (no JSON)

Captures the position only, not the move history (so pop() can't cross the boundary, like a chess FEN) or an installed policies seam (re-supply it on the rebuilt game if needed).

Readable names

Engine ids/slugs are underscored (grizzly_bears_6); recover the printed name (with MTG's casing) anywhere:

g.name("grizzly_bears_6")        # 'Grizzly Bears'
g.name(commander_id)             # 'Magda, Brazen Outlaw'  (corpus-correct, not naive title-case)
g.card(id)["name"]               # card()/permanents() views include a 'name' field
g.describe(move)                 # 'alice: cast Grizzly Bears'  (vs Game.describe_move(move), id-form)

Imperfect information (what one seat sees)

obs = g.observation("alice")     # a redacted, READ-ONLY Game from alice's seat (§103)
obs.hand("alice")                # alice's real hand
obs.hand("bob")                  # [] — hidden; but obs.hand_count("bob") gives the true size
obs.library_size("bob")          # true count (rows hidden, count carried)
obs.battlefield(); obs.life()    # public info kept
obs.library_top()                # cards this seat scried/looked at, in order (else [])
obs.push(...)                    # RuntimeError — observations are read-only (legality lives on the true game)

This is the view an agent should reason over to "play like a real player." Note terminal/turn bookkeeping (_loser, _turn) is redacted, so is_game_over()/turn_number aren't meaningful on an observation — it's a snapshot for reasoning about an in-progress position, not for driving.

Engine backend

g = mtg.Game(incremental=True)   # in-process incremental update backend; g.incremental reports if it engaged
mtg.engine_available()           # 'incremental' | 'native' | 'inproc' | 'interpreter'

incremental=True is byte-identical to the default backend (verified across full games), ~2× faster on large states and neutral on small. It's a process-global selection (the driver reads it per eval) and degrades gracefully — if the souffle fork isn't built it warns and falls back.

Players

A Player answers the two kinds of decision the engine raises, both wired through the shim: a top-level move (choose_move(game) — pick from game.legal_moves) and an internal sub-choice (decide(view, key, options, default) — a target/mode/blocks/discard resolved inside step). play() runs a full game with each seat driven by its player; a seat's player drives both its moves and its sub-choices.

from mtg import Player, RandomPlayer, GreedyPlayer, play

result = play({"alice": RandomPlayer(), "bob": GreedyPlayer()}, seed=7)
result.outcome()          # ('alice', 'bob lost')

# a custom heuristic — subclass Player, override choose_move; you get the whole Game
class Aggro(Player):
    def choose_move(self, game):
        atk = [m for m in game.legal_moves if m[0] == "attack"]
        return max(atk, key=lambda m: len(m[1])) if atk else game.legal_moves[0]
    # (optionally also override decide() to steer targets/modes/blocks)

play({"alice": Aggro(), "bob": RandomPlayer(seed=1)}, variant="two-player", seed=3)
  • RandomPlayer(seed=None) — uniform-random; seed=None draws from the game's own seeded RNG (so the game is reproducible from its seed), an explicit seed gives the player its own independent RNG.
  • GreedyPlayer (= base Player) — takes the engine's hand-tuned default at every decision; a complete, legal opponent with zero config.
  • Custom — subclass Player. choose_move(game) is where heuristics/search live (use game.copy(), game.key(), game.push/pop, the zone accessors); override decide() only if you want to steer the nested sub-choices too.

(London mulligan resolves with the engine default — keep — before play begins.)

Benchmarking a heuristic

Drop in a Player and measure it. Self-play is fast (no Forge); the seats are swapped every other game and Random vs Random lands at ~0.50, so the win-rate is honest.

from mtg import benchmark, RandomPlayer, Player

class MyBot(Player):
    def choose_move(self, game): ...

benchmark(MyBot(), games=50)                 # -> {'win_rate': 0.62, 'avg_turns': 14.1, 'games_per_s': 1.4, ...}
benchmark(MyBot(), RandomPlayer(seed=1))     # vs a fixed-seed baseline

To benchmark against Forge (Forge referees — the source of truth — and runs the mirror: mtg reconstructs the board from Forge's observation stream every decision and runs in parallel):

from mtg.benchmark import benchmark_vs_forge
benchmark_vs_forge("engine", games=10)
# -> {'bot_win_rate': 0.3, 'avg_turns': 23,
#     'mirror_modeled_frac': 0.93,   # how much of Forge's real game mtg could MODEL
#     'mirror_endorsed_frac': 0.17,  # how much it could independently ENDORSE as legal (the engine gaps)
#     'source_of_truth': 'forge', ...}

benchmark_vs_forge needs Forge installed and spins up one JVM per game (heavy). The two mirror fractions are the differential-fidelity signal — high modeled / low endorsed means mtg recognizes the board but can't yet derive the play, which is the precise place to extend the rules engine.

ReBeL player (search over public belief states)

ReBeLPlayer is a search agent in the style of ReBeL (Brown et al. 2020, arXiv:2007.13544): at each decision it solves a depth-limited CFR subgame over the public belief state and acts on the resulting average (near-equilibrium) strategy. CPU-only, no GPU, no engine changes — it sits entirely on the shim.

from mtg import ReBeLPlayer, RandomPlayer, play
play({"alice": ReBeLPlayer(worlds=4, iterations=100, depth=3, time_budget=5.0),
     "bob":   RandomPlayer()})
  • Belief / PBSobserve.observe is the public projection; the belief is worlds determinizations (full states sampling the opponent's hidden hand/library partition from the known deck). Each is a perfect-information world the engine evaluates exactly — the perfect-info aspect used inside the PBS.
  • Shared infosets — regrets are keyed by observe()-derived infostate, so the acting seat shares one strategy across worlds it can't distinguish (the imperfect-information constraint, for free).
  • Depth-limited CFR — the subgame is expanded once per world (the only env.step cost), then CFR runs as arithmetic over the cached tree; leaves are scored by value_fn (default heuristic_value).
  • perfect_info=True solves the true game (one world) — same machinery, full information.
  • Bounded by worlds / iterations / depth / action_cap / time_budget.

The leaf evaluator is the lever: with the naive heuristic and a shallow horizon it plays ~even with random (combat damage sits one ply past a depth-2 leaf; depth 3 sees it). Strength comes from replacing the leaf with a trained value function (ReBeL's value network) — value_fn= is the hook.

Trained value net (mtg.rebel_train, CPU)

A tiny numpy value net + self-play data generation — the leaf that fixes the horizon. CPU-only, no GPU (needs numpy; kept out of the top-level import so import mtg stays dependency-free).

from mtg.rebel_train import train
from mtg.rebel import ReBeLPlayer

value_fn = train(games=200, epochs=300)          # self-play -> features + Monte-Carlo outcome -> fit
player = ReBeLPlayer(value_fn=value_fn, worlds=6, iterations=120, depth=2)
value_fn.net.save("vnet")                         # ...and TinyValueNet.load("vnet") later

features(state, seat) is the public-belief feature vector; generate(games, player_factory=…) produces (X, y) (target = the deciding seat's eventual outcome) from any data-generating policy (default random self-play, cheap). A more ReBeL-faithful target is the CFR root value under ReBeL self-play — heavier, optional.

Training run on a fixed pairing, with periodic Forge checks

from mtg.rebel_train import train_loop
out = train_loop(rounds=12, train_decks=("mono_green_landfall", "mono_white_soldiers"),
                 benchmark_every=4, save_path="rebel_vnet")  # alice=green (trains), bob=white (random)
out["history"]   # every round: data size; benchmark rounds also carry win_rate_vs_random + the Forge result

EVERY round is a cheap training round: the training seat (alice) plays self-play against a random opponent (bob) with a value-greedy agent that improves as the net does, then the net refits and saves — no search, so it's light. BENCHMARKS are infrequent (every benchmark_every rounds): there it evaluates ReBeL(net) vs random, and then tests the net against Forge (the only heavy step; forge=False to skip). Fixed known decks make the determinization belief exact ("perfect information to train against"). You can also fire the Forge test off directly — the trained net plays a Forge seat (Forge = source of truth) via a reconstruct + 1-ply net-rank policy:

from mtg.rebel_train import TinyValueNet, NetValue
from mtg.benchmark import benchmark_vs_forge
benchmark_vs_forge(NetValue(TinyValueNet.load("rebel_vnet")), games=5, timeout=300)

(Forge games are heavy and can stall on a small box — use a generous timeout / the Mac Mini; the Forge deck is a ForgeVsBot archetype, a cross-domain check rather than the exact training deck.)

Variants

import game as _setup
g = mtg.Game(_setup.COMMANDER_DECKS, variant="commander", seed=1,
                    commanders=_setup.COMMANDERS)   # §903: 40 life, command zone
mtg.self_play(seed=7)                          # full random game -> winner
result = play({"alice": RandomPlayer(), "bob": RandomPlayer()},
              _setup.COMMANDER_DECKS, variant="commander", commanders=_setup.COMMANDERS)  # players, any variant

Decks (human-readable lists)

Deck lists are plain text — N Card Name or just Card Name, one per line (#/// comments and blank lines ignored). The names are the human-readable oracle names the engine resolves directly (no slug/encode step), so you can hand-write a .txt and read it line by line.

import mtg
mtg.bundled_decks()                          # ['izzet_prowess', 'mono_black_zombies',
                                                    #  'mono_green_landfall', 'selesnya_landfall']
deck = mtg.load_deck("mono_green_landfall")  # bundled name -> flat list ['Forest', 'Forest', ...]
deck = mtg.load_deck("/path/to/my.txt")      # ...or any file path

g = mtg.Game({"alice": mtg.load_deck("izzet_prowess"),
                     "bob":   mtg.load_deck("mono_black_zombies")})
mtg.benchmark(MyBot(), decks={"alice": mtg.load_deck("selesnya_landfall"),
                                     "bob":   mtg.load_deck("selesnya_landfall")})

Four 40-card, limited-style pools ship with the package (mtg/decks/*.txt): mono_green_landfall, selesnya_landfall (GW go-wide), izzet_prowess (UR spells), mono_black_zombies. MTGO/Arena/.dck exports also parse (the loader skips [Section] headers and Name=/metadata lines).

Forge as the source of truth

The default engine is mtg (our datalog rules referee). For a mode where Forge is authoritative and one seat is the "forge player" (Forge's own AI), use mtg.forge — the game actually runs in Forge, and the mtg side connects as a bot seat (over forge_bridge's socket) answering each Forge decision:

import mtg.forge as wf
if wf.forge_available():                      # needs a built Forge fatjar + JDK 17 ($FORGE / $JDK)
    r = wf.play_forge(bot="engine")           # Forge AI vs the mtg 'stockfish' bot; FORGE judges
    print(r["winner"], r["turns"])            # winner is Forge's verdict — the source of truth
    # bot="random" for the baseline seat; witch_deck/opp_deck pick ForgeVsBot archetypes ('vanilla','infect')

This is the only faithful way to involve Forge: there is no reverse bridge that lets Forge's AI choose moves inside a mtg Game, so when Forge is the truth, the game runs in Forge. It reuses the wired forge_integration orchestration (ForgeVsBot + run_bot), so it spins up the JVM and can be slow / memory-hungry; set $JVM_HEAP / $GAME_TIMEOUT on small hosts. The mtg bot seat is driven by an forge_bridge obs-policy ('engine' = the win-search bot, 'random' = baseline); a RandomPlayer maps to 'random', any other Player to 'engine'. (A custom Game-based Player can't drive a Forge seat — Forge hands it an observation + Forge-ids, not a mtg state + moves; write a forge_bridge policy for a custom Forge bot.)

How it sits on the engine

datalog/engine_rules.dl   derives the consequences of a state   — the rules
driver.py                 applies them, owns chance/choice       — the shim
env.py                    enumerates legal actions, steps PURELY — the referee
game.py (top level)       builds a real game (decks, mulligan)   — the setup
mtg.Game           a stateful object wrapping all of it   — this package

Packaging status

This package is the pure-Python API layer. It currently imports the engine modules from the repository root and the engine resolves its data files (datalog/engine_rules.dl, the souffle fork) relative to the current working directory — so for now, run from the repo root. Making it pip install python-mtg-clean (path-independent data lookups + a prebuilt .so/wheel so no C++ toolchain is needed) is deferred and tracked in PACKAGING.md.

Check which engine backend built on your machine:

mtg.engine_available()   # 'incremental' | 'native' | 'inproc' | 'interpreter'

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

python_mtg-0.1.0-py3-none-manylinux_2_34_x86_64.whl (6.1 MB view details)

Uploaded Python 3manylinux: glibc 2.34+ x86-64

python_mtg-0.1.0-py3-none-macosx_11_0_universal2.whl (6.8 MB view details)

Uploaded Python 3macOS 11.0+ universal2 (ARM64, x86-64)

File details

Details for the file python_mtg-0.1.0-py3-none-manylinux_2_34_x86_64.whl.

File metadata

File hashes

Hashes for python_mtg-0.1.0-py3-none-manylinux_2_34_x86_64.whl
Algorithm Hash digest
SHA256 3f6302fbc8598e556a7ed24bf1ad51fdf2d7b432a2a91f302b2079240b5fd220
MD5 4c98aa15890faeb8228509bbbc80bfa8
BLAKE2b-256 dce226a777824f0faba854e8f64551f97564768d6944f991127f3c322c1e4442

See more details on using hashes here.

File details

Details for the file python_mtg-0.1.0-py3-none-macosx_11_0_universal2.whl.

File metadata

File hashes

Hashes for python_mtg-0.1.0-py3-none-macosx_11_0_universal2.whl
Algorithm Hash digest
SHA256 44a6d08e9db3f904f2ee779967cb5b042df6a334defd7ea96e2ee9f265043749
MD5 df1734a386453d7204602380718cc2d3
BLAKE2b-256 77196469c4d8280ff808c5484d83b3ca8830cd253d893d6dac6029d98d5c7d71

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page