From ef1c188807b44e5952d63ab33de7d28c4d3940ec Mon Sep 17 00:00:00 2001 From: Derek Choi Date: Wed, 28 Oct 2026 12:47:05 +0000 Subject: [PATCH] Initial Commit --- .dockerignore | 3 + .env.example | 10 ++ .gitignore | 2 + Dockerfile | 9 ++ docker-compose.yml | 26 +++++ package.json | 26 +++++ src/db.ts | 40 ++++++++ src/server.ts | 239 +++++++++++++++++++++++++++++++++++++++++++++ src/standings.ts | 61 ++++++++++++ src/tables.ts | 80 +++++++++++++++ tsconfig.json | 8 ++ views/head.ejs | 13 +++ views/home.ejs | 8 ++ views/league.ejs | 11 +++ views/login.ejs | 3 + views/session.ejs | 31 ++++++ 16 files changed, 570 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/db.ts create mode 100644 src/server.ts create mode 100644 src/standings.ts create mode 100644 src/tables.ts create mode 100644 tsconfig.json create mode 100644 views/head.ejs create mode 100644 views/home.ejs create mode 100644 views/league.ejs create mode 100644 views/login.ejs create mode 100644 views/session.ejs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..281e557 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39359b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# generate with: openssl rand -hex 32 +DB_PASSWORD= +SECRET_KEY= +# 10+ characters +ADMIN_PASSWORD= +# set to true once served over https via your reverse proxy +COOKIE_SECURE=false +# address the app listens on. 127.0.0.1 = this machine only. +# Reverse proxy on another machine (e.g. over Tailscale)? use this host's tailscale IP (tailscale ip -4) +BIND_ADDR=127.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca70714 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:24-slim +WORKDIR /srv +COPY package*.json ./ +RUN npm install --omit=dev +COPY src src +COPY views views +ENV NODE_ENV=production +USER node +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f91809d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: league + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: league + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U league"] + interval: 5s + retries: 10 + web: + build: . + restart: unless-stopped + depends_on: + db: {condition: service_healthy} + environment: + DATABASE_URL: postgres://league:${DB_PASSWORD}@db:5432/league + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + SECRET_KEY: ${SECRET_KEY} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + ports: ["${BIND_ADDR:-127.0.0.1}:8000:8000"] # set BIND_ADDR to this host's Tailscale IP; never 0.0.0.0 +volumes: + pgdata: diff --git a/package.json b/package.json new file mode 100644 index 0000000..feb4321 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "mtg-league", + "private": true, + "type": "module", + "scripts": { + "start": "node --import tsx src/server.ts", + "dev": "node --import tsx --watch src/server.ts", + "check": "tsc" + }, + "dependencies": { + "cookie-session": "^2.1.0", + "ejs": "^3.1.10", + "express": "^5.1.0", + "multer": "^2.0.0", + "pg": "^8.13.0", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@types/cookie-session": "^2.0.49", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^24.0.0", + "@types/pg": "^8.11.0", + "typescript": "^5.6.0" + } +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2c3e9f6 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,40 @@ +import pg from "pg"; + +pg.types.setTypeParser(1082, (v) => v); // keep DATE columns as 'YYYY-MM-DD' strings + +export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +export type Q = Pick; + +export const q = async (sql: string, params: unknown[] = []): Promise => + (await pool.query(sql, params)).rows; + +export async function tx(fn: (c: pg.PoolClient) => Promise): Promise { + const c = await pool.connect(); + try { + await c.query("BEGIN"); + const r = await fn(c); + await c.query("COMMIT"); + return r; + } catch (e) { + await c.query("ROLLBACK"); + throw e; + } finally { + c.release(); + } +} + +// Same tables as the earlier Python version, so an existing database keeps working. +export const init = () => pool.query(` +CREATE TABLE IF NOT EXISTS players (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL); +CREATE TABLE IF NOT EXISTS leagues (id SERIAL PRIMARY KEY, name TEXT NOT NULL, best_n INT NOT NULL DEFAULT 6); +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, league_id INT NOT NULL REFERENCES leagues(id), number INT NOT NULL, date DATE NOT NULL); +CREATE TABLE IF NOT EXISTS matches ( + id SERIAL PRIMARY KEY, session_id INT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, round INT NOT NULL, + p1_id INT NOT NULL REFERENCES players(id), p2_id INT REFERENCES players(id), p1_deck TEXT, p2_deck TEXT, + p1_wins INT DEFAULT 0, p2_wins INT DEFAULT 0, draws INT DEFAULT 0); +CREATE TABLE IF NOT EXISTS session_results ( + id SERIAL PRIMARY KEY, session_id INT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + player_id INT NOT NULL REFERENCES players(id), points INT NOT NULL, deck TEXT, + wins INT, draws INT, losses INT, UNIQUE (session_id, player_id)); +`); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..d0a9427 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,239 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import cookieSession from "cookie-session"; +import express, { type NextFunction, type Request, type Response } from "express"; +import multer from "multer"; +import { init, q, tx, type Q } from "./db.ts"; +import { computeStandings } from "./standings.ts"; +import { csvRows, extract, parseTable } from "./tables.ts"; + +const { SECRET_KEY, ADMIN_PASSWORD } = process.env; +if (!SECRET_KEY || !ADMIN_PASSWORD) throw new Error("Set SECRET_KEY and ADMIN_PASSWORD"); +if (SECRET_KEY.length < 32 || ADMIN_PASSWORD.length < 10 || [SECRET_KEY, ADMIN_PASSWORD].some((v) => v.startsWith("change-me"))) + throw new Error("Weak secrets: SECRET_KEY needs 32+ chars (openssl rand -hex 32), ADMIN_PASSWORD 10+ chars, and neither may be the example value"); + +class HttpError extends Error { + status: number; + constructor(status: number, msg: string) { super(msg); this.status = status; } +} + +const app = express(); +app.set("trust proxy", 1); // behind your reverse proxy +app.disable("x-powered-by"); +app.use((_req, res, next) => { + const nonce = crypto.randomBytes(16).toString("base64"); + res.locals.nonce = nonce; + res.set({ + "Content-Security-Policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + "Cache-Control": "no-store", // pages differ for admins, so never let a shared cache keep them + }); + next(); +}); +// CSRF defence in depth (the cookie is also SameSite=Lax): refuse cross-site state-changing requests +function crossSite(req: Request) { + const site = req.get("sec-fetch-site"); + if (site) return site !== "same-origin" && site !== "none"; + const origin = req.get("origin"); + if (!origin) return false; + try { return new URL(origin).host !== req.host; } catch { return true; } +} +app.use((req, _res, next) => + next(!["GET", "HEAD", "OPTIONS"].includes(req.method) && crossSite(req) ? new HttpError(403, "Cross-site request blocked") : undefined)); +app.set("view engine", "ejs"); +app.set("views", path.resolve("views")); +app.use(express.urlencoded({ extended: false, limit: "2mb" })); +app.use((req, _res, next) => { req.body ??= {}; next(); }); +app.use(cookieSession({ + name: "league", keys: [SECRET_KEY], maxAge: 7 * 864e5, httpOnly: true, sameSite: "lax", + secure: process.env.COOKIE_SECURE === "true", +})); +const isAdmin = (req: Request) => !!(req.session as any)?.admin; +app.use((req, res, next) => { res.locals.admin = isAdmin(req); next(); }); +const admin = (req: Request, _res: Response, next: NextFunction) => + next(isAdmin(req) ? undefined : new HttpError(403, "Admin only")); +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5e6 } }); + +const idOf = (req: Request) => { + const n = Number(req.params.id); + if (!Number.isInteger(n) || n < 1 || n > 2 ** 31 - 1) throw new HttpError(404, "Not found"); + return n; +}; +const find = async (table: "leagues" | "sessions", id: number) => { + const row = (await q(`SELECT * FROM ${table} WHERE id=$1`, [id]))[0]; + if (!row) throw new HttpError(404, "Not found"); + return row; +}; +const sha = (s: string) => crypto.createHash("sha256").update(s).digest(); +const cell = (v: unknown) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v)); + +async function standingsFor(l: { id: number; best_n: number }) { + const [ms, rs, ps] = await Promise.all([ + q("SELECT m.* FROM matches m JOIN sessions s ON s.id=m.session_id WHERE s.league_id=$1", [l.id]), + q("SELECT r.* FROM session_results r JOIN sessions s ON s.id=r.session_id WHERE s.league_id=$1", [l.id]), + q("SELECT id, name FROM players"), + ]); + return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n); +} + +/** name -> player id; players are created on first sight, names match case-insensitively. */ +async function resolver(c: Q) { + const cache = new Map((await c.query("SELECT id, name FROM players")).rows.map((p) => [p.name.toLowerCase(), p.id])); + return async (name: string): Promise => { + if (!name) return null; + let id = cache.get(name.toLowerCase()); + if (id == null) { + id = (await c.query("INSERT INTO players(name) VALUES($1) RETURNING id", [name])).rows[0].id as number; + cache.set(name.toLowerCase(), id); + } + return id; + }; +} + +// ---------- public pages ---------- +app.get("/", async (_req, res) => res.render("home", { leagues: await q("SELECT * FROM leagues ORDER BY id DESC") })); + +app.get("/league/:id", async (req, res) => { + const lg = await find("leagues", idOf(req)); + const { rows, decks } = await standingsFor(lg); + const sessions = await q("SELECT * FROM sessions WHERE league_id=$1 ORDER BY number", [lg.id]); + res.render("league", { lg, rows, decks, sessions }); +}); + +app.get("/api/league/:id/leaderboard", async (req, res) => { + res.json((await standingsFor(await find("leagues", idOf(req)))).rows); +}); + +app.get("/session/:id", async (req, res) => { + const sid = idOf(req); + const gs = await find("sessions", sid); + const lg = await find("leagues", gs.league_id); + const ms = await q( + `SELECT m.*, a.name AS p1, b.name AS p2 FROM matches m JOIN players a ON a.id=m.p1_id + LEFT JOIN players b ON b.id=m.p2_id WHERE m.session_id=$1 ORDER BY m.round, m.id`, [sid]); + const rounds: { round: number; matches: any[] }[] = []; + for (const m of ms) { + let r = rounds.find((x) => x.round === m.round); + if (!r) rounds.push((r = { round: m.round, matches: [] })); + r.matches.push(m); + } + // existing results as CSV so the admin can edit and resubmit + const csvtext = ms.map((m) => [m.round, m.p1, m.p1_deck ?? "", m.p2 ?? "", m.p2_deck ?? "", m.p1_wins, m.p2_wins, m.draws].map(cell).join(",")).join("\n"); + const results = (await q( + `SELECT r.*, p.name FROM session_results r JOIN players p ON p.id=r.player_id + WHERE r.session_id=$1 ORDER BY r.points DESC, p.name`, [sid])) + .map((r) => ({ ...r, rec: r.wins == null ? "" : [r.wins, r.draws, r.losses].join("-") })); + const known = (await q( + `SELECT deck FROM (SELECT deck FROM session_results UNION SELECT p1_deck FROM matches + UNION SELECT p2_deck FROM matches) d WHERE deck IS NOT NULL ORDER BY 1`)).map((r) => r.deck); + res.render("session", { gs, lg, rounds, csvtext, results, known }); +}); + +// ---------- auth ---------- +app.get("/login", (_req, res) => res.render("login", { error: null })); +const attempts = new Map(); // failed logins per IP, 15 minute window +app.post("/login", (req, res) => { + const ip = req.ip ?? "?", now = Date.now(); + for (const [k, v] of attempts) if (now - v.t > 15 * 60e3) attempts.delete(k); + const a = attempts.get(ip); + if (a && a.n >= 10) throw new HttpError(429, "Too many failed logins, try again in 15 minutes"); + if (crypto.timingSafeEqual(sha(String(req.body.password ?? "")), sha(ADMIN_PASSWORD))) { + attempts.delete(ip); + (req.session as any).admin = true; + return res.redirect(303, "/"); + } + attempts.set(ip, { n: (a?.n ?? 0) + 1, t: a?.t ?? now }); + res.render("login", { error: "Wrong password" }); +}); +app.post("/logout", (req, res) => { req.session = null; res.redirect(303, "/"); }); + +// ---------- admin ---------- +app.post("/admin/league", admin, async (req, res) => { + const name = String(req.body.name ?? "").trim(); + if (!name) throw new HttpError(400, "Name required"); + const [l] = await q("INSERT INTO leagues(name, best_n) VALUES($1,$2) RETURNING id", [name, Math.min(100, Math.max(1, parseInt(req.body.best_n) || 6))]); + res.redirect(303, `/league/${l.id}`); +}); + +app.post("/admin/league/:id/session", admin, async (req, res) => { + const lg = await find("leagues", idOf(req)); + const date = String(req.body.date ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new HttpError(400, "Bad date"); + const [s] = await q( + `INSERT INTO sessions(league_id, number, date) + VALUES($1, (SELECT COALESCE(MAX(number),0)+1 FROM sessions WHERE league_id=$1), $2) RETURNING id`, [lg.id, date]); + res.redirect(303, `/session/${s.id}`); +}); + +app.post("/admin/session/:id/delete", admin, async (req, res) => { + const [s] = await q("DELETE FROM sessions WHERE id=$1 RETURNING league_id", [idOf(req)]); // matches/results cascade + if (!s) throw new HttpError(404, "Not found"); + res.redirect(303, `/league/${s.league_id}`); +}); + +// Replace a session's matches: round, player1, deck1, player2, deck2, p1_wins, p2_wins, draws (blank player2 = bye) +app.post("/admin/session/:id/import", admin, async (req, res) => { + const sid = idOf(req); + await find("sessions", sid); + const data = String(req.body.data ?? ""); + const rows = csvRows(data, data.includes("\t") ? "\t" : ",").flatMap((c, i) => { + if (c[0].startsWith("#") || c[0].toLowerCase() === "round") return []; + c = [...c, ...Array(8).fill("")].slice(0, 8); + const [rnd, w1, w2, dr] = [c[0], c[5] || "0", c[6] || "0", c[7] || "0"].map(Number); + if (![rnd, w1, w2, dr].every((n) => Number.isInteger(n) && n >= 0 && n <= 99)) throw new HttpError(400, `Row ${i + 1}: round and scores must be whole numbers from 0 to 99`); + if (!c[1]) throw new HttpError(400, `Row ${i + 1}: player 1 is missing`); + return [{ rnd, a: c[1], da: c[2] || null, b: c[3], db: c[4] || null, w1, w2, dr }]; + }); + await tx(async (c) => { + const pid = await resolver(c); + await c.query("DELETE FROM matches WHERE session_id=$1", [sid]); + for (const r of rows) { + const bye = !r.b; + await c.query( + `INSERT INTO matches(session_id, round, p1_id, p2_id, p1_deck, p2_deck, p1_wins, p2_wins, draws) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [sid, r.rnd, await pid(r.a), await pid(r.b), r.da, r.db, bye ? 2 : r.w1, bye ? 0 : r.w2, bye ? 0 : r.dr]); + } + }); + res.redirect(303, `/session/${sid}`); +}); + +// Replace a session's imported standings from a Melee export (CSV/JSON, uploaded or pasted); keeps decks already set +app.post("/admin/session/:id/import-standings", admin, upload.single("file"), async (req, res) => { + const sid = idOf(req); + await find("sessions", sid); + const text = req.file ? req.file.buffer.toString("utf8") : String(req.body.data ?? ""); + let rows; + try { rows = extract(parseTable(text)); } catch (e) { throw new HttpError(400, (e as Error).message); } + await tx(async (c) => { + const old = new Map((await c.query("SELECT player_id, deck FROM session_results WHERE session_id=$1", [sid])).rows.map((r) => [r.player_id, r.deck])); + const pid = await resolver(c); + await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]); + for (const r of rows) { + const p = (await pid(r.name))!; + await c.query( + `INSERT INTO session_results(session_id, player_id, points, deck, wins, draws, losses) + VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (session_id, player_id) DO NOTHING`, + [sid, p, r.points, r.deck || old.get(p) || null, ...(r.wdl ?? [null, null, null])]); + } + }); + res.redirect(303, `/session/${sid}`); +}); + +app.post("/admin/session/:id/decks", admin, async (req, res) => { + const sid = idOf(req); + for (const [k, v] of Object.entries(req.body)) { + const m = /^deck_(\d+)$/.exec(k); + if (m) await q("UPDATE session_results SET deck=$1 WHERE id=$2 AND session_id=$3", [String(v).trim() || null, Number(m[1]), sid]); + } + res.redirect(303, `/session/${sid}`); +}); + +app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { + if (!err.status) console.error(err); + res.status(err.status ?? 500).type("text/plain").send(err.status ? err.message : "Server error"); +}); + +await init(); +app.listen(8000, () => console.log("MTG league listening on :8000")); diff --git a/src/standings.ts b/src/standings.ts new file mode 100644 index 0000000..a6945ee --- /dev/null +++ b/src/standings.ts @@ -0,0 +1,61 @@ +import type { Wdl } from "./tables.ts"; + +type M = { session_id: number; p1_id: number; p2_id: number | null; p1_deck: string | null; p2_deck: string | null; p1_wins: number; p2_wins: number }; +type R = { session_id: number; player_id: number; points: number; deck: string | null; wins: number | null; draws: number | null; losses: number | null }; + +/** 3 pts per match win, 1 per draw, 0 per loss (a bye is a win). A session's score is the sum of its match + * points; the leaderboard adds up each player's best `bestN` sessions. Sessions with match rows use those; + * otherwise the imported standings are used. */ +export function computeStandings(ms: M[], rs: R[], names: Map, bestN: number) { + const per = new Map>(); // player -> session -> points + const rec = new Map(); + const decks = new Map(); + const partial = new Set(); // players with a session whose W-D-L is unknown + const addPts = (p: number, s: number, pts: number) => { + const m = per.get(p) ?? new Map(); + m.set(s, (m.get(s) ?? 0) + pts); + per.set(p, m); + }; + const bump = (map: Map, k: K, i: number, n = 1) => { + const t = map.get(k) ?? [0, 0, 0]; + t[i] += n; + map.set(k, t); + }; + + const matched = new Set(ms.map((m) => m.session_id)); + for (const m of ms) { + const sides: [number | null, string | null, number, number][] = [ + [m.p1_id, m.p1_deck, m.p1_wins, m.p2_wins], + [m.p2_id, m.p2_deck, m.p2_wins, m.p1_wins], + ]; + for (const [pid, deck, me, opp] of sides) { + if (pid == null) continue; + const i = me > opp ? 0 : me === opp ? 1 : 2; // W / D / L + addPts(pid, m.session_id, [3, 1, 0][i]); + bump(rec, pid, i); + if (m.p2_id != null && deck) bump(decks, deck, i); + } + } + for (const r of rs) { + if (matched.has(r.session_id)) continue; + addPts(r.player_id, r.session_id, r.points); + if (r.wins == null) { partial.add(r.player_id); continue; } + [r.wins, r.draws ?? 0, r.losses ?? 0].forEach((n, i) => { + bump(rec, r.player_id, i, n); + if (r.deck) bump(decks, r.deck, i, n); + }); + } + + const rows = [...per].map(([pid, sessions]) => { + const top = [...sessions.values()].sort((a, b) => b - a); + return { + name: names.get(pid) ?? "?", + score: top.slice(0, bestN).reduce((a, b) => a + b, 0), + total: top.reduce((a, b) => a + b, 0), + sessions: top.length, + rec: partial.has(pid) ? null : rec.get(pid) ?? null, + }; + }).sort((a, b) => b.score - a.score || b.total - a.total || a.name.localeCompare(b.name)); + const sum = (w: Wdl) => w[0] + w[1] + w[2]; + return { rows, decks: [...decks].sort((a, b) => sum(b[1]) - sum(a[1])) }; +} diff --git a/src/tables.ts b/src/tables.ts new file mode 100644 index 0000000..2cea5b2 --- /dev/null +++ b/src/tables.ts @@ -0,0 +1,80 @@ +// Parse a standings export (Melee JSON, or a generic CSV/JSON) into rows. No dependencies. +export type Wdl = [number, number, number]; +export type Row = { name: string; points: number; deck: string; wdl: Wdl | null }; +type Rec = Record; +export class InputError extends Error {} + +const PLAYER = ["player", "playername", "username", "playerusername", "name", "displayname", "team", "teamname"]; +const POINTS = ["points", "matchpoints", "totalpoints", "pts"]; +const DECK = ["deck", "deckname", "archetype", "decklistname", "decklistarchetype"]; + +const norm = (k: string) => k.toLowerCase().replace(/[^a-z0-9]/g, ""); +const flat = (o: Rec, prefix = ""): Rec => + Object.entries(o).reduce((out, [k, v]) => + v && typeof v === "object" && !Array.isArray(v) ? { ...out, ...flat(v, prefix + k) } : { ...out, [prefix + k]: v }, {}); + +export function csvRows(text: string, delim: string): string[][] { + const rows: string[][] = []; + let row: string[] = [], cell = "", quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quoted) { + if (ch !== '"') cell += ch; + else if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false; + } else if (ch === '"' && cell === "") quoted = true; + else if (ch === delim) { row.push(cell); cell = ""; } + else if (ch === "\n" || ch === "\r") { + if (ch === "\r" && text[i + 1] === "\n") i++; + row.push(cell); rows.push(row); row = []; cell = ""; + } else cell += ch; + } + if (cell !== "" || row.length) { row.push(cell); rows.push(row); } + return rows.map((r) => r.map((c) => c.trim())).filter((r) => r.some((c) => c !== "")); +} + +export function parseTable(input: string): Rec[] { + const text = input.trim().replace(/^\uFEFF/, ""); + if (!text) return []; + if (text[0] === "[" || text[0] === "{") { + let j: any = JSON.parse(text); + while (j && !Array.isArray(j) && typeof j === "object") j = Object.values(j).find(Array.isArray); // unwrap {"data": [...]} + return Array.isArray(j) ? j.filter((r) => r && typeof r === "object") : []; + } + const first = text.split(/\r?\n/)[0]; + const count = (c: string) => first.split(c).length - 1; + const delim = count("\t") ? "\t" : count(";") > count(",") ? ";" : ","; + const [head, ...body] = csvRows(text, delim); + return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +// Melee's standings export holds one snapshot per round, so use the final round. Real names are used +// (falling back to display name, then username). +function melee(rows: Rec[]): Row[] { + const last = rows.reduce((m, r) => Math.max(m, Number(r.RoundNumber) || 0), 0); + return rows.filter((r) => Number(r.RoundNumber) === last).flatMap((r): Row[] => { + const p = r.Team?.Players?.[0] ?? {}; + const name = String(p.Name || p.DisplayName || p.Username || "").trim(); + if (!name) return []; + const nums = [r.Points, r.MatchWins, r.MatchDraws, r.MatchLosses].map(Number); + if (!nums.every((n) => Number.isInteger(n) && n >= 0 && n < 1e6)) throw new InputError(`Bad numbers for ${name}`); + return [{ name, points: nums[0], deck: "", wdl: [nums[1], nums[2], nums[3]] }]; + }); +} + +export function extract(input: Rec[]): Row[] { + if (!input.length) throw new InputError("No rows found in that file"); + if ("Team" in input[0] && "MatchWins" in input[0] && "RoundNumber" in input[0]) return melee(input); + const rows = input.map((r) => flat(r)); + const by = new Map(Object.keys(rows[0]).map((h) => [norm(h), h])); + const col = (aliases: string[]) => by.get(aliases.find((a) => by.has(a)) ?? ""); + const [pc, sc, dc] = [col(PLAYER), col(POINTS), col(DECK)]; + if (!pc || !sc) throw new InputError("Couldn't find the player/points columns. Headers in the file: " + [...by.values()].join(", ")); + return rows.flatMap((r): Row[] => { + const name = String(r[pc] ?? "").trim(); + if (!name) return []; + const pts = Number(String(r[sc]).trim()); + if (!Number.isFinite(pts)) throw new InputError(`Bad points value for ${name}: ${r[sc]}`); + if (pts < 0 || pts > 1e6) throw new InputError(`Bad points value for ${name}: ${r[sc]}`); + return [{ name, points: Math.round(pts), deck: dc ? String(r[dc] ?? "").trim() : "", wdl: null }]; + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5ed27f9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "noEmit": true, "allowImportingTsExtensions": true, + "esModuleInterop": true, "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/views/head.ejs b/views/head.ejs new file mode 100644 index 0000000..bfd81df --- /dev/null +++ b/views/head.ejs @@ -0,0 +1,13 @@ + + +MTG League + + + diff --git a/views/home.ejs b/views/home.ejs new file mode 100644 index 0000000..78e8851 --- /dev/null +++ b/views/home.ejs @@ -0,0 +1,8 @@ +<%- include('head') %> +

MTG Leagues

+
    <% leagues.forEach(l => { %>
  • <%= l.name %> (best <%= l.best_n %> sessions count)
  • <% }) %>
+<% if (admin) { %> +

New league

+
+ best sessions count
+<% } %> diff --git a/views/league.ejs b/views/league.ejs new file mode 100644 index 0000000..16694f4 --- /dev/null +++ b/views/league.ejs @@ -0,0 +1,11 @@ +<%- include('head') %> +

<%= lg.name %>

+

Leaderboard (best <%= lg.best_n %> sessions)

+ +<% rows.forEach((r, i) => { %> +<% }) %>
#PlayerCountedAll sessionsPlayedW-D-L
<%= i + 1 %><%= r.name %><%= r.score %><%= r.total %><%= r.sessions %><%= r.rec ? r.rec.join('-') : '–' %>
+

Sessions

+ +<% if (admin) { %>
<% } %> +

Deck performance (match W-D-L)

+<% decks.forEach(([d, r]) => { %><% }) %>
DeckW-D-L
<%= d %><%= r.join('-') %>
diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..885f0d3 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,3 @@ +<%- include('head') %> +

Admin login

<% if (error) { %>

<%= error %>

<% } %> +
diff --git a/views/session.ejs b/views/session.ejs new file mode 100644 index 0000000..8c27387 --- /dev/null +++ b/views/session.ejs @@ -0,0 +1,31 @@ +<%- include('head') %> +

<%= lg.name %> – Session <%= gs.number %> (<%= gs.date %>)

+

← Standings

+<% rounds.forEach(r => { %>

Round <%= r.round %>

+<% r.matches.forEach(m => { %> + + + +<% }) %>
<%= m.p1 %> (<%= m.p1_deck || '?' %>)<% if (m.p2) { %><%= m.p2 %> (<%= m.p2_deck || '?' %>)<% } else { %>BYE<% } %><%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %>
<% }) %> +<% if (!rounds.length && !results.length) { %>

No results yet.

<% } %> +<% if (results.length) { %>

Standings (imported)

+
+ +<% results.forEach(r => { %> +<% }) %>
PlayerPointsW-D-LDeck
<%= r.name %><%= r.points %><%= r.rec %><% if (admin) { %><% } else { %><%= r.deck || '' %><% } %>
+<% if (admin) { %><% known.forEach(k => { %><% } %> +
+

If this session also has match rows, those are used for scoring instead.

<% } %> +<% if (admin) { %> +

Import Melee standings export

+

Upload the CSV/JSON from Melee's Standings tab (or paste it). Saving replaces this session's imported standings but keeps decks already entered.

+
+
+
+

Import matches by hand

+

One match per line: round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws. Leave player 2 blank for a bye. +Comma- or tab-separated. Saving replaces this session's matches.

+
+
+
+<% } %>