masi 14daed4c54 Add market prices to the card index and the deck total
Prices were already in the Scryfall bulk download, so this adds no
dependency and no new fetch: the work is deciding what a card's price
means, given that the index keeps one row per card and a card has many
printings.

It is the median of the ordinary printings. The kept printing underprices
about a third of the legal pool because it is chosen for art and identity
and is usually a cheap reprint; the maximum is worse, since premium and
bundled listings put a $870 Sol Ring in the data. The median lands where a
player recognises the number and ignores the outliers. It is a market
value, not a build cost.

Two filter decisions are deliberate and easy to undo by accident: Reserved
List printings are included, because that is where Commander's banned
Power 9 live and excluding them priced Ancestral Recall and Time Walk at
nothing, while memorabilia is excluded because a Collectors' Edition box is
not a single card. Banned cards fall back to their banned printings.

deckValue counts unpriced cards instead of treating them as free, so a
total is always labelled with what it left out.

Also implements scripts/verify-cards.mjs. package.json and the index skill
both documented `pnpm cards:verify` but the script did not exist; it now
runs in deploy and fails on a card priced at exactly zero, which is the
failure mode the missing-price guard exists to prevent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:17:09 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00
2026-10-01 21:12:19 -03:00

MAGIK

A Magic: The Gathering Commander (EDH) deck builder. Pick a Commander, build the 100-card list, and get legality checked live as you type — against real Scryfall data, with no account and no server-side state.

Live at https://magic.marcospaulo.dev.br

What it does

  • Deck builder — search Commander-legal cards, add them, watch the validator.
  • Legality engine — 100 cards, one Commander or a legal Partner pair, colour identity contained in the Commander, singleton except basic lands, no banned cards.
  • Share links — a deck is encoded into its own URL. There is no save button and no account; a deck is a link.
  • Browse / Analyze — browse the legal pool, and break a list down by mana curve, colour identity and type line.
  • Batch import — a folder of .txt decklists, several lists pasted together, or the JSON backup Settings exports. Unresolved names are reported by name rather than dropped.
  • Play — a board for the phone lying face-up during a game. One card per player holding only what is used mid-turn: a name, a life total, a minus and a plus in opposite corners, and the counters on that player. All four seats fit one screen with no scrolling. Commander damage, the ±5 and ±10 steps, adding a counter, renaming and turn order are one tap away in a sheet, because they are used between turns rather than during them. The state survives a reload, the screen is kept awake, and it runs full screen.
  • Card art — hot-linked from Scryfall's CDN.
  • Market prices — a USD value per card and a total for the deck, so a list can be costed before it is built.
  • Usable on a phone — one column in the order a player reads it, touch targets sized for a thumb, modals as bottom sheets, and a card detail that opens rather than being hidden.

Architecture

Three pieces, and the boundaries between them are the design.

  Scryfall bulk download                (build time, host only)
  default_cards.json ──► scripts/build-cards.mjs ──► data/cards.db
                                                          │
                                                          │ baked into image
                                                          ▼
  ┌──────────────────────── one container, one process ────────────────────────┐
  │  server/index.mjs — node:http                                            │
  │    ├── /api/*     read-only queries over data/cards.db (node:sqlite)     │
  │    └── /*         static files from dist/, SPA shell fallback             │
  │  dist/            React 19 SPA, built by Vite                            │
  └──────────────────────────────────────────────────────────────────────────┘
                                                          │
                                          K3s: ClusterIP → Traefik
                                                          │
                                          Caddy on Oracle VPS, over Tailscale

The decisions behind it

One process, one container, no ORM. node:http and node:sqlite are built into Node 26. There is no Express, no Prisma, no native module to compile — the API image needs no build toolchain at all.

The card database is a build artifact, not a live dependency. Commander filtering is a one-off question about a static dataset, so it is answered once, ahead of time, into SQLite. The runtime never talks to Scryfall: no rate limits, no CORS, no upstream outage. The trade is that the index goes stale when Wizards publishes a Banned & Restricted List change; pnpm cards:build rebuilds it and that is the whole refresh story. See .claude/skills/commander-card-index.

No user state, therefore no PVC. Decks live in the browser and travel in the share URL. The service holds nothing, so a new release replaces the old one outright with nothing to migrate or restore. That is also why replicas: 1 is correct rather than a scaling compromise.

Rules come from data, not from a hardcoded list. Ban status and per-card colour identity are read from Scryfall, so the Commander Committee changing the ban list is a data refresh, not a code change.

A card's price is a median, and the reason is not obvious. The index keeps one row per card, but a card has many printings at very different prices, so the price has to be aggregated. Neither obvious choice works: the printing the row keeps is chosen for identity and art, so it is usually a cheap reprint and underprices about a third of the legal pool; and the maximum is far worse, since premium and bundled listings put a $870 Sol Ring in the data. The median of the ordinary printings lands where a player recognises the number — Forest $0.29, Sol Ring $2.17, Mystical Tutor $19.07 — and is unmoved by those outliers.

It is a market value, not a build cost: the cheapest printing is well below it, and the index does not answer that question. Cards with no open-market price (Un-cards) and sealed product (Collectors' Edition boxes) are stored as null and rendered as "no price", never as $0.00. pnpm cards:verify fails the build if a card is priced at exactly zero.

API

All endpoints are read-only GET.

Endpoint Purpose
/api/health liveness + card count
/api/search filter the legal pool (name, color, type, cmc, commander, …)
/api/commanders search restricted to Commander-eligible cards
/api/cards?id=… batch fetch, repeated param, max 400
/api/cards/:uuid single card
/api/lookup?name=… resolve by name, repeated param

Layout

server/         node:http API + static serving; db.mjs is the card-index reader
src/lib/        commander.js is the rules engine; deckStore.js owns decks;
                table.js is the game-in-progress reducer
src/components/ card browser, detail modal, commander picker, validator, import
src/routes/     Decks, DeckBuilder, Browse, Analyze, Table, Settings
scripts/        build-cards.mjs, deploy.sh
deploy/k8s/     Namespace + Deployment + Service
templates/      project scaffolding
.claude/skills/ project-specific agent skills (see below)

commander.js, deckStore.js and table.js are plain JavaScript on purpose — node --test exercises them directly, and the UI imports the same file. There is no second copy of the rules, and the life total on the table is asserted by the same reducer that draws it.

Running it

pnpm install
pnpm cards:build     # downloads Scryfall bulk, builds data/cards.db (~1 min)
pnpm dev             # vite on :5173, api on :8099
pnpm test            # 71 tests: rules engine, deck/store handling, import, table, prices
pnpm typecheck
pnpm cards:verify    # card-index invariants, including a $0.00 price check
pnpm build
pnpm api             # serve the built SPA + API on one port

Deploy to the homelab cluster with scripts/deploy.sh (see .claude/skills/homelab-web-deploy).

Project skills

.claude/skills/ holds the project-specific knowledge that would otherwise live only in a chat log. They are the reusable part of building this thing:

Skill Owns
commander-card-index building, verifying and repairing data/cards.db; picking up B&R List changes
commander-deck-rules the legality engine in commander.js and deck/share handling in deckStore.js
homelab-web-deploy the Dockerfile / manifest / deploy-script pattern for a stateless web service on the K3s homelab

Status and history

PROGRESS.md is the working log: the stack decision with its rationale, the design direction, the task list, and the deploy history. Read it if you want the why rather than the what.

S
Description
Magic: The Gathering Commander (EDH) deck builder
Readme 17 MiB
Languages
TypeScript 47.3%
JavaScript 34%
CSS 16.6%
Shell 1%
Dockerfile 0.7%
Other 0.4%