Skip to content

tecnomancy/magic

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

75 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

magic — MTG AI Judge

License: MIT Node 22 Next.js 15

A Magic: The Gathering judge built as a deterministic game state machine with semantic RAG over the Comprehensive Rules, IPG, MTR, JAR and Scryfall rulings. Optionally synthesises rulings through Claude Code (subscription) — no paid API key required from contributors.

Reference deployment: https://magic.mago.team (the tecnomancy community instance). You can run your own the same way — the stack is self-hostable on any Linux box with Docker.


Contents


What it does

  1. Rules Q&A/judge/ask. Ask anything, get a cited ruling.
    • Retrieval-only mode: concatenates top-k rule chunks from hybrid FTS5 + sqlite-vec retrieval. Works offline, zero LLM.
    • Semantic mode: the same retrieval, but the chunks + resolved cards + retrieval metadata are routed through claude-proxy → Claude Code → your subscription. Stateless, one round-trip.
    • The rendered answer is not plain text — citations like [CR 603.3b] become cyan pills with hover popovers showing the retrieved rule chunk, card names become gold pills linking to Scryfall with oracle text on hover, and jargon (APNAP, LIFO, stack, priority, ETB, …) surfaces a short gloss on first mention. Keyboard-accessible, screen-reader-aware.
  2. Live arbiter/judge/live/<matchId>. Drives a match through a formal xstate machine. Illegal actions are rejected with the rule citation before they mutate state.
  3. Replay analyzer/judge/replay. Paste a log, see illegal moves with explanations.

Architecture

flowchart LR
    subgraph browser [Browser]
        UI[Next.js pages<br/>/judge/ask · /matches · /cards]
    end

    subgraph docker [Docker stack]
        subgraph magic [magic container &#58;8085]
            api[Next.js API routes<br/>/api/ask · /api/matches · ...]
            gsm[xstate game<br/>state machine]
            rag[RAG pipeline<br/>chunks_fts + chunks_vec]
        end
        subgraph embedder [embedder container &#58;8086]
            emb[MiniLM<br/>all-MiniLM-L6-v2<br/>384-d, L2-norm]
        end
        dbrules[(data/rules/chunks.db<br/>sqlite + sqlite-vec)]
        dbcards[(data/cards/oracle.db<br/>sqlite + sqlite-vec)]
    end

    subgraph host [Host &#40;optional&#41;]
        proxy[claude-proxy FastAPI<br/>docker bridge &#58;5099]
        cli[claude -p<br/>subscription auth]
    end

    UI -->|HTTPS| api
    api --> gsm
    api --> rag
    rag -->|embed query| emb
    rag --> dbrules
    api --> dbcards
    api -.semantic mode.-> proxy
    proxy --> cli
Loading
  • Next.js 15 (magic container) — SSR pages + API routes. xstate game engine under lib/gamestate/. RAG under lib/rag.ts.
  • embedder (Python FastAPI)sentence-transformers/all-MiniLM-L6-v2, 384-d, L2-normalised. One shared instance for ingestion and query time.
  • sqlite-vec + FTS5chunks.db (Comprehensive Rules) and oracle.db (Scryfall bulk) each carry both a vector index (vec0) and a full-text index (fts5). Retrieval fuses them via Reciprocal Rank Fusion.
  • claude-proxy (optional) — host-side FastAPI that shells out to claude -p. Binds to the Docker bridge IP only; never exposed publicly.

Data flow

Question → Answer (semantic mode)

sequenceDiagram
    autonumber
    participant U as User
    participant N as Next.js /api/ask
    participant R as Rules DB<br/>&#40;sqlite-vec + FTS5&#41;
    participant C as Cards DB<br/>&#40;sqlite-vec + FTS5&#41;
    participant E as Embedder
    participant P as claude-proxy
    participant K as claude -p

    U->>N: POST {question}
    N->>E: embed(question)
    E-->>N: 384-d vector
    par Rules retrieval
        N->>R: FTS5 BM25 top-pool
        N->>R: vec0 MATCH top-pool
        R-->>N: chunks + distances + ranks
    and Card resolution
        N->>C: lexical name lookup &#40;n-grams&#41;
        N->>C: vec0 MATCH on question embedding
        C-->>N: resolved cards + distances
    end
    N->>N: RRF fuse chunks<br/>dedupe cards
    N->>N: build prompt<br/>&#40;question + meta + chunks + cards&#41;
    N->>P: POST /ask {prompt, system}
    P->>K: spawn claude -p
    K-->>P: answer text
    P-->>N: {answer}
    N-->>U: {answer, mode, citations, cards_used, retrieval_meta, hits}
Loading

When CLAUDE_PROXY_URL is unset or the proxy times out, step 9 is skipped and the route returns mode: "retrieval-fallback" with the concatenated top-k chunks as answer.

Ingestion pipeline

flowchart TD
    cr[Comprehensive Rules<br/>CR.txt] --> chunker[lib/rules/chunker]
    chunker --> chunks[NormalizedChunk&#91;&#93;<br/>id, title, body, tags]
    chunks --> embed1[embedBatch<br/>→ embedder &#58;8086]
    embed1 --> upsert1[upsertChunks<br/>→ chunks + chunks_fts + chunks_vec]
    upsert1 --> rulesdb[(data/rules/chunks.db)]

    scry[Scryfall oracle bulk<br/>~37k cards] --> import[importOracle]
    import --> cards[cards table<br/>+ cards_fts]
    cards --> embed2[embedBatch<br/>&#40;name + type + oracle_text&#41;]
    embed2 --> upsert2[upsertCardVector<br/>→ cards_vec + cards_vec_map]
    upsert2 --> cardsdb[(data/cards/oracle.db)]

    style rulesdb fill:#1b1b1b,stroke:#0ff
    style cardsdb fill:#1b1b1b,stroke:#0ff
Loading

One-time commands:

docker compose exec magic pnpm ingest:rules       # ~2 min
docker compose exec magic pnpm ingest:cards       # ~2 min (downloads Scryfall bulk)
docker compose exec magic pnpm ingest:cards-vec   # ~20 min (embeds all 37k cards)

Live arbiter state machine (xstate v5)

stateDiagram-v2
    [*] --> beginning
    beginning --> main_one: end untap/upkeep/draw
    main_one --> combat: declare combat
    main_one --> main_one: cast spell / activate
    combat --> declare_attackers
    declare_attackers --> declare_blockers
    declare_blockers --> combat_damage
    combat_damage --> end_of_combat
    end_of_combat --> main_two
    main_two --> end_step: pass
    end_step --> cleanup
    cleanup --> beginning: next turn
    cleanup --> [*]: game ends
Loading

Every transition is guarded by a pure predicate (lib/gamestate/guards.ts). Illegal transitions are rejected with a CR citation before the store mutates. The retrieval layer never validates game state.


Quick start

Everything runs in containers. No local Node or Python needed.

1. Clone & boot the stack

git clone https://github.com/tecnomancy/magic.git
cd magic
docker compose up -d --build
curl http://localhost:8085/api/health
# {"status":"ok","rules_chunks":0,"cards":0,...}

2. Ingest the rules corpus

docker compose exec magic pnpm ingest:rules

3. Ingest cards + embeddings

docker compose exec magic pnpm ingest:cards
docker compose exec magic pnpm ingest:cards-vec

After these three steps /api/health should show rules_chunks: ~3800, cards: ~37000.

4. (Optional) Enable Claude-backed synthesis — see next section.

5. Try it

curl -s -X POST http://localhost:8085/api/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"What is haste?"}' | jq .

Or open http://localhost:8085/judge/ask.


Claude-backed synthesis (optional)

The semantic path uses your own Claude Code subscription. There is no product-level paid API billing. Contributors who don't want to enable this still get retrieval-only answers.

Host-side setup

# 1. Install Claude Code & log in with your subscription
#    https://docs.claude.com/claude-code
claude /login

# 2. Start the proxy (binds to docker bridge only — never public)
python3 -m uvicorn server:app \
  --host 172.17.0.1 --port 5099 \
  --app-dir services/claude-proxy &

curl http://172.17.0.1:5099/health
# {"status":"ok","service":"claude-proxy"}

Container-side

The Next.js app reads CLAUDE_PROXY_URL which defaults to http://host.docker.internal:5099 in docker-compose.yml. extra_hosts already maps host.docker.internal:host-gateway, so the container reaches the proxy out of the box.

How it fails soft

If CLAUDE_PROXY_URL is empty or the proxy is unreachable or it returns a non-2xx or it times out (default 240 s), /api/ask returns

{ "mode": "retrieval-fallback", "answer": "<concatenated chunks>", ... }

No error to the client, no 5xx to monitor. The UI shows the fallback answer and the user can retry.


Workflow & tests

Spec-driven + TDD. See specs/SPEC.md and specs/WORKFLOW.md.

SPEC -> RED -> GREEN -> REFACTOR -> REVIEW

No spec = no code. No test = no impl. No CI green = no merge.

flowchart LR
    spec[specs/features/*.spec.md] --> red[tests/<br/>failing]
    red --> green[lib/ + app/<br/>min impl]
    green --> refactor[clean diff]
    refactor --> review[PR review]
    review --> merge[main]
    merge --> deploy[your deployment]
Loading

Run the suite:

docker compose exec magic pnpm test            # vitest
docker compose exec magic pnpm test:coverage
docker compose exec magic pnpm lint            # tsc + eslint

Coverage gates live in vitest.config.ts (see SPEC.md for per-area thresholds).


Production

docker compose -f docker-compose.prod.yml up -d --build

The production compose file binds the Next.js container to 127.0.0.1:8085 so you can front it with whatever reverse proxy you prefer (nginx, Caddy, Traefik, Cloudflare Tunnel, ...) to terminate TLS and map to your own domain.

flowchart LR
    u[User] -->|https&#58;//your-domain| tls[Reverse proxy<br/>TLS terminator<br/>nginx · caddy · traefik · ...]
    tls -->|proxy_pass| prod[magic container &#58;8085]
    prod --> emb[embedder container &#58;8086]
    prod --> rulesdb[(chunks.db)]
    prod --> cardsdb[(oracle.db)]
Loading

Contributing

  1. Open an issue describing the bug or feature. Reference a spec file (or propose one) in specs/.
  2. Fork, branch from develop.
  3. Write the spec. Then the failing tests. Then the minimum impl.
  4. PR with a clear description; CI must be green.

We follow the Spec Symphony workflow. Details in specs/WORKFLOW.md.

Project structure

magic/
├─ app/                # Next.js pages + API routes
├─ lib/
│  ├─ gamestate/       # xstate machine + rule engine
│  ├─ rules/           # CR chunker, DB handles
│  ├─ rag.ts           # hybrid retrieval
│  ├─ embed.ts         # embedder client
│  ├─ cards/           # Scryfall lookup + vector search
│  └─ semantic/        # synth, claude-synth, card-extract
├─ embedder/           # Python FastAPI with MiniLM
├─ services/
│  └─ claude-proxy/    # host-side Claude Code wrapper
├─ scripts/            # ingest-rules, ingest-cards, ingest-card-vectors
├─ tests/              # vitest suites
└─ specs/
   ├─ SPEC.md
   ├─ WORKFLOW.md
   ├─ adrs/            # architecture decisions
   └─ features/        # per-feature specs

Design notes

  • Deterministic rules engine (ADR-0003). The xstate machine is the source of truth for game legality, never the LLM.
  • Local embeddings (ADR-0002). all-MiniLM-L6-v2 is fast, free, runs anywhere, 384-d, well studied for rules-text similarity.
  • Subscription-only LLM (ADR-0007 + ADR-0010). No product-level paid API billing. Contributors without a Claude subscription keep the retrieval-only mode.
  • Commander first-class (ADR-0008). Deck validation + CMDR zone handling live in the same engine; not retrofitted.
  • WotC Fan Content Policy (ADR-0006 + NOTICE.md). All MTG names, rules text, mana symbols, trademarks are property of Wizards of the Coast LLC. This project is not affiliated with or endorsed by Wizards of the Coast.

License

Code: MIT (see LICENSE). MTG content under the WotC Fan Content Policy (see NOTICE.md). The embedder container pulls the MiniLM model under its upstream license.

About

MTG AI Judge — deterministic rules engine + semantic RAG over CR/IPG/MTR/JAR + optional Claude Code subscription synthesis

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors