尚未收集仓库信息。
尚未检测到更新。GitHub 发布版本后会显示在这里。
Tournament dashboard for hosting MTG nights at home: TV broadcast view with live life totals, phone score keeping, Swiss pairings, ELO history, and AI wizard portraits that take damage as life drops.
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗A self-contained tournament dashboard for hosting Magic: The Gathering nights at home — pairings, real-time life totals on a TV, phone-based score keeping, ELO history across events, and AI-generated wizard portraits whose appearance changes as players take damage.
Built because no off-the-shelf tool ships a TV broadcast view with live life totals, and the MTG Companion app has no public API to integrate with.
/leagues/[slug]) — every wizard and every event belongs to a league (e.g. "Lexington Dads Magic Draft"). New players self-create their identity from /leagues/[slug]/claim straight from a phone; the per-league cookie survives across every event in that league, so a returning player just taps their wizard once./events/[id]/broadcast) — full-screen 16:9 layout for casting to a TV. Live life totals, Swiss pairings, round timer, standings, damage/heal pulse animations. Each player's wizard portrait is the background of their cell and crossfades through three damage tiers as their life drops. Once the final round closes the same screen flips into a final-ranking layout — one wizard card per player in finishing order, with the champion in their victory portrait, the last-place finisher in their defeat portrait, and a per-round opponent + result + ELO-delta breakdown underneath each card./events/[id]/play) — players adjust either side's life and report game wins, a match draw, or "they won" from a one-handed phone UI. Updates fan out over SSE and a 3 s polling reconcile loop, so even if a life_changed event is lost in transit both phones converge to the server total. When the organizer advances rounds, every phone auto-jumps to its new pairing — no pull-to-refresh./events/[id]/claim shows the event roster as tappable cards. A QR code in the broadcast corner links straight there. If the visitor already has a league cookie, the page recognizes them and offers a single "Continue as X" button. Both /players/[id] and /leagues/[slug] surface a prominent "▶ Scorekeeper" CTA the moment a player is on an open event's roster, so a phone that just finished wizardize has an obvious next step./events/[id]/manage) — "Preview round N" stages auto-generated Swiss pairings in a pending state that players don't see. The organizer can swap two players between tables, drop a pair (their previous-round match stays live on those two phones so they can keep playing while everyone else moves on), re-roll the auto pairings, or add a manual pairing from the unpaired list before tapping "Confirm and start →"./files/<name> proxy route./leagues/[slug]/schedule) — two complementary tools. The calendar opens a standing run of dates up front (generated from a cadence — "every other Monday from Aug 31" — or added one at a time); everyone RSVPs ✅ / 🟡 / ❌ per date from a phone and can change their answer any time, while the organizer fills in the plan for each night: the set being drafted, who's hosting, and where. Date polls stay for the Doodle-style case where the night isn't on the calendar yet: propose candidates, everyone marks availability, pick the winner. Either one promotes into a real event in one tap, pre-rostering whoever said yes / if-need-be.src/lib/pairings/swiss.ts.src/lib/pairings/team-draft.ts, src/lib/team-draft.ts, both unit-tested. The broadcast shows a team scoreboard above the tables and team chips on every card; final placements rank the winning team's players first. The final-ranking layout and the share image both switch to two rows — the winning team across the top in victory portraits, the other side below in defeat portraits, each row ordered by the player's own record.git clone <repo-url> mtg-dash
cd mtg-dash
npm install
# 1. Provision Vercel-linked resources (Neon DB, Blob storage, Upstash Redis)
vercel link
vercel install neon
vercel blob create-store mtg-dash-blob --access=public --yes
vercel install upstash/upstash-kv --plan paid -m primaryRegion=iad1
vercel env pull .env.local
# 2. Apply migrations + seed
# Seeding creates two leagues: "Demo League" (6 placeholder players) and
# "Lexington Dads Magic Draft" (empty). Skip the seed if you want a blank
# install and create your own league via the DB or seed file.
npm run db:migrate
npm run db:seed
# 3. (Optional) Start the local FLUX server for wizard portraits.
# The app talks to http://127.0.0.1:8000 in dev (and imagegen.mised.tech
# in prod via a Cloudflare tunnel). Wizardize is skipped gracefully if
# the server isn't reachable. See https://github.com/filipstrand/mflux.
# 4. Develop
npm run dev # http://localhost:3000
Required env vars (.env.local):
DATABASE_URL=postgresql://... # Neon, from `vercel env pull`
BLOB_READ_WRITE_TOKEN=... # Vercel Blob, from `vercel env pull`
KV_REST_API_URL=... # Upstash for Redis (Realtime),
KV_REST_API_TOKEN=... # both from `vercel env pull`
IMAGE_GEN_URL=http://127.0.0.1:8000 # local FLUX; prod uses imagegen.mised.tech
NEXT_PUBLIC_GA4_MEASUREMENT_ID=G-... # optional, enables Google Analytics 4
BLOB_READ_WRITE_TOKEN is required for wizard portrait storage — uploadPortrait throws if it's missing. KV_REST_API_URL / KV_REST_API_TOKEN enable cross-instance SSE pub/sub via @upstash/realtime; when unset, an in-process Map covers single-host dev (fine for npm run dev / lan / verify).
When NEXT_PUBLIC_GA4_MEASUREMENT_ID is set, the root layout embeds gtag.js via @next/third-parties/google's <GoogleAnalytics> component — pageviews land in the app-traffic dashboard. Unset means analytics is silently disabled (no gtag round-trip). Build-time inlined, so changing it requires a rebuild. Scoped to Production on Vercel so preview pageviews don't pollute the prod stream.
| Command | What it does |
|---|---|
npm run dev | Next.js dev server with HMR |
npm run build && npm start | Production build + serve |
npm run lan | Build + serve on 0.0.0.0:3002, prints the LAN URL so phones on the same Wi-Fi can connect. Useful when guests' phones can't reach prod (no internet at a venue, etc.). |
npm test | Vitest unit tests (Swiss pairings, ELO math, avatar tier picker, pub/sub fallback, Realtime schema) |
npm run lint | ESLint |
npm run verify | End-to-end harness: spins up an isolated _verify_-prefixed league + 8-player event, drives every action, exercises the FLUX wizardize pipeline if the server is reachable, then cleans up. ~6 s without FLUX, ~2½ min with it. Set SKIP_FLUX=1 to skip the image-gen step. |
npm run db:generate | Generate a Drizzle migration from schema changes |
npm run db:migrate | Apply pending migrations to Neon |
npm run db:studio | Open Drizzle Studio |
npm run db:seed | Idempotent. Creates the "Demo League" (6 placeholder players) and "Lexington Dads Magic Draft" (empty). Safe to re-run. |
npm run migrate:blob | One-shot. Walks every players row pointing at a legacy /files/<name> URL, uploads the bytes to Vercel Blob at avatars/<playerId>/<tier>.jpg, and rewrites the DB columns. Already run once on prod; re-runs are no-ops. |
https://mtg.capxun.com/ or the canonical Vercel alias). If you want to run a tournament without internet, npm run lan on the host laptop and have phones connect to the LAN URL it prints./leagues/lexington-dads-magic-draft/claim (or whatever your league's slug is) and tap an existing wizard or "Create wizard" to onboard. The league cookie sticks for a year./leagues/[slug]/events/new, pick the roster, hit create./claim. If they already have a league cookie they see "Continue as X"; otherwise they tap their wizard portrait./events/[id]/manage, reviews the proposed pairings (swap / drop / manual-pair as needed), then "Confirm and start →". Every phone on the event auto-jumps to its new match the moment the round goes live.matches.time_turn) so both phones and the TV agree. When matches finish (or the organizer overrides via /manage), the round can be closed. If a pair wants to keep playing into the next round, drop them from the next round's preview — their old match stays in-progress on their phones and they can finish at their own pace while everyone else moves on./play page flip into the final-ranking layout (ranked horizontal grid of wizard cards with per-round W/L/D and ELO deltas underneath each).game_nights are dates the league has already committed to (unique per (league_id, starts_at), so re-running a recurrence is idempotent) and collect a rolling night_rsvps row per player. date_polls pick ONE date out of several candidates and then close. Both carry a one-way link into events (source_night_id / source_poll_id, each uniquely indexed so a double-tap can't spawn two events). Recurrence math lives in src/lib/recurrence.ts and steps whole calendar days on wall time, so a 7pm series stays at 7pm across a DST change.players rows belong to a single league (players.league_id) and carry a durable league_token. Tapping a wizard on /leagues/[slug]/claim sets an mtg_league_<leagueId> httpOnly cookie scoped to /, good for a year, so the player is recognized across every event in that league. Portable cross-league identities are a future migration.event_players row still gets a 192-bit join_token. Claiming on /events/[id]/claim sets both the per-event cookie (used by /play and the realtime views) and the league cookie. No real auth — friends-only assumption.src/lib/pubsub.ts publishes to Redis Streams via @upstash/realtime (channel = event:<eventId>); the SSE route subscribes with historyLimit: 50 so reconnects after Vercel's 300 s function-duration cap replay missed events automatically. When KV_REST_API_URL is unset (local dev), pubsub.ts falls back to an in-process Map<eventId, Set<Subscriber>>. Broadcast and Play clients consume via EventSource + a polling reconcile (10 s on the TV, 3 s on phones).PlayerSide calls pickAvatarUrl while a match is in progress and pickMatchOutcomeAvatar once it resolves; both cascade through sibling tiers so older 3-tier players still render correctly./edit.MIT.
avatars/<playerId>/<tier>.jpgallowOverwrite: true?v=<ts>imagegen.mised.techgenerateWizardAction flips a wizard_job_started_at flag, returns in under a second, and uses after() from next/server to run the ~2½ min FLUX pipeline past the response. Each FLUX fetch carries AbortSignal.timeout(240_000) so the pipeline bails ~60 s before Vercel's 300 s function-duration kill. If after() is killed mid-job anyway, sweepStaleWizardJobs() clears the in-progress flag on the next page render (6-minute threshold + null-avatar check)./icon/{192,512} and /apple-icon are dynamic — they read the visitor's mtg_league_<id> cookie, look up the claimed wizard, and serve that player's victory portrait. Falls back to the bundled May 12 champion when no cookie is set. The OS caches the install-time PNG forever, so the "trophy at install" property holds.