Files
commander-ai-lab/services/farmhouse_deck_links.py
T
fez5587andClaude Sonnet 5.5 fee8ded52d FarmHouse: run Python-only on a local LLM; simulator plays real games
Starts from upstream 54cc5e9. Cards reach the simulator with type and text (filled from the Scryfall bulk DB) and the right quantities; the LLM engine asks several questions per turn and with reasoning off; Moxfield and Archidekt deck links work in every import and card names resolve locally; deck builds are fitted to the commander's colour identity; Scryfall format/User-Agent/429 handling fixed; coach init fixed. See FARMHOUSE-CHANGES.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 16:42:58 -05:00

155 lines
7.0 KiB
Python

"""Deck LINKS for every deck import (copied into the app as services/farmhouse_deck_links.py by the Dockerfile).
Upstream could import from an Archidekt or EDHREC link on the simulator page only; the Deck Builder import took pasted text and
nothing else, and Moxfield (where most people keep decks) was not accepted anywhere. This reads a Moxfield or Archidekt deck link and
returns what the existing importers already understand: a profile for the simulator import, or plain "[Commander] / [Main]" text for
the Deck Builder's two imports (so a link pasted into the same box works, and the card-by-card Scryfall matching is unchanged).
Moxfield has no official public API; api2.moxfield.com answers server-side requests for PUBLIC decks (tested from the cluster
2026-10-26: HTTP 200, a named User-Agent is enough). A private or deleted deck comes back 401/403/404, and the message says so and
says what to do instead (Moxfield: Export > Copy as text, then paste that). Nothing here logs in to anything.
"""
import json
import re
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
UA = "CommanderAILab/3.0 (self-hosted)"
MOXFIELD_API = "https://api2.moxfield.com/v2/decks/all/"
_MOX = re.compile(r"^https?://(?:www\.)?moxfield\.com/decks/([A-Za-z0-9_-]+)(?:[/?#].*)?$", re.I)
_ARC = re.compile(r"^https?://(?:www\.)?archidekt\.com/decks/(\d+)(?:[/?#].*)?$", re.I)
UNSUPPORTED = ("That link is not one I can read. Moxfield and Archidekt deck links work; for anything else, paste the decklist as text "
"(Moxfield: Export > Copy as text).")
def link_kind(text):
"""(kind, id, url) when `text` is nothing but one http(s) link, else None. kind: moxfield | archidekt | unsupported."""
t = (text or "").strip()
if not t or len(t.split()) != 1 or not re.match(r"^https?://", t, re.I):
return None
m = _MOX.match(t)
if m:
return "moxfield", m.group(1), t
m = _ARC.match(t)
if m:
return "archidekt", m.group(1), t
return "unsupported", None, t
def _get_json(url):
with urlopen(Request(url, headers={"User-Agent": UA, "Accept": "application/json"}), timeout=25) as resp:
return json.loads(resp.read().decode("utf-8", "replace"))
def fetch_moxfield(deck_id, attempts=4):
last = None
for i in range(attempts):
try:
return _get_json(MOXFIELD_API + deck_id)
except HTTPError as exc:
if exc.code in (401, 403, 404):
raise ValueError("Moxfield would not share that deck (it is private, unlisted for sharing, or the link is wrong). "
"Open it in Moxfield, choose Export > Copy as text, and paste that here instead.")
if exc.code in (429, 500, 502, 503, 504):
last = exc
try:
wait = float(exc.headers.get("Retry-After") or 0)
except (TypeError, ValueError):
wait = 0.0
time.sleep(max(wait, 2.0) + i * 2.0)
continue
raise ValueError("Moxfield answered HTTP %s for that deck." % exc.code)
except (ValueError, OSError) as exc:
raise ValueError("Could not read the deck from Moxfield (%s)." % str(exc)[:80])
raise ValueError("Moxfield is rate-limiting this server right now (HTTP %s). Try again in a minute." % (last.code if last else "429"))
def _board(deck, key):
out = {}
for name, entry in (deck.get(key) or {}).items():
card = (entry or {}).get("card") or {}
label = card.get("name") or name
try:
qty = int((entry or {}).get("quantity") or 1)
except (TypeError, ValueError):
qty = 1
out[label] = out.get(label, 0) + max(qty, 1)
return out
def moxfield_profile(deck, url):
"""The same profile dict the Archidekt importer builds. Sideboard, maybeboard and companions are left out (the 99 + commander)."""
commanders, main = _board(deck, "commanders"), _board(deck, "mainboard")
profile = {
"name": deck.get("name") or "Moxfield deck",
"commander": next(iter(commanders), None),
"source": "Moxfield",
"sourceUrl": deck.get("publicUrl") or url,
"commanders": commanders,
"mainboard": main,
"colorIdentity": list(deck.get("colorIdentity") or []),
"totalCards": sum(commanders.values()) + sum(main.values()),
}
if profile["commander"] and not deck.get("name"):
profile["name"] = "%s — Moxfield" % profile["commander"]
return profile
def fetch_profile(url):
kind = link_kind(url)
if not kind or kind[0] == "unsupported":
raise ValueError(UNSUPPORTED)
if kind[0] == "moxfield":
profile = moxfield_profile(fetch_moxfield(kind[1]), kind[2])
else:
from services.import_helpers import _fetch_archidekt_deck # the app's own Archidekt reader
try:
profile = _fetch_archidekt_deck(kind[1])
except HTTPError as exc:
raise ValueError("Archidekt would not share that deck (HTTP %s). Is it public?" % exc.code)
if not profile["commanders"] and not profile["mainboard"]:
raise ValueError("That deck has no cards in it (or no main deck I can read).")
return profile
def profile_to_text(profile):
"""Plain text in the Deck Builder's own format: [Commander] then [Main], one 'qty name' per line."""
lines = ["[Commander]"]
lines += ["%d %s" % (q, n) for n, q in profile["commanders"].items()]
lines += ["", "[Main]"]
lines += ["%d %s" % (q, n) for n, q in profile["mainboard"].items()]
return "\n".join(lines) + "\n"
def local_card(name, conn=None):
"""{'id', 'name'} from the local Scryfall bulk database (113k cards, no network), or None. Matches the exact name, or the front
face of a double-faced / split card ('Fire' finds 'Fire // Ice'). The Deck Builder's importers only use these two fields, and
looking each card up on Scryfall's website instead hit its rate limit (HTTP 429) after a few dozen cards of a 100-card list,
which the app reported as 'not found'."""
try:
if conn is None:
from services.scryfall_bulk import get_bulk_db
conn = get_bulk_db()
n = (name or "").strip()
if not n:
return None
like = n.replace("!", "!!").replace("%", "!%").replace("_", "!_") + " // %"
row = conn.execute("SELECT scryfall_id, name FROM cards WHERE LOWER(name) = LOWER(?) OR LOWER(name) LIKE LOWER(?) ESCAPE '!' "
"ORDER BY (LOWER(name) = LOWER(?)) DESC, edhrec_rank ASC LIMIT 1", (n, like, n)).fetchone()
return {"id": row[0], "name": row[1]} if row else None
except Exception:
return None
def expand(text):
"""(text, deck name). A lone Moxfield/Archidekt link becomes the decklist text; anything else comes back unchanged.
A lone link we cannot read raises ValueError with a message meant for the person."""
kind = link_kind(text)
if kind is None:
return text, None
profile = fetch_profile(kind[2])
return profile_to_text(profile), profile["name"]