Initial Commit
This commit is contained in:
commit
ef1c188807
16 files changed
+570
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<pg.Pool | pg.PoolClient, "query">;
|
||||||
|
|
||||||
|
export const q = async (sql: string, params: unknown[] = []): Promise<any[]> =>
|
||||||
|
(await pool.query(sql, params)).rows;
|
||||||
|
|
||||||
|
export async function tx<T>(fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
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));
|
||||||
|
`);
|
||||||
+239
@@ -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<string, number>((await c.query("SELECT id, name FROM players")).rows.map((p) => [p.name.toLowerCase(), p.id]));
|
||||||
|
return async (name: string): Promise<number | null> => {
|
||||||
|
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<string, { n: number; t: number }>(); // 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<number, string | null>((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"));
|
||||||
@@ -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<number, string>, bestN: number) {
|
||||||
|
const per = new Map<number, Map<number, number>>(); // player -> session -> points
|
||||||
|
const rec = new Map<number, Wdl>();
|
||||||
|
const decks = new Map<string, Wdl>();
|
||||||
|
const partial = new Set<number>(); // 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<number, number>();
|
||||||
|
m.set(s, (m.get(s) ?? 0) + pts);
|
||||||
|
per.set(p, m);
|
||||||
|
};
|
||||||
|
const bump = <K>(map: Map<K, Wdl>, 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])) };
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
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<Rec>((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 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
|
||||||
|
"strict": true, "noEmit": true, "allowImportingTsExtensions": true,
|
||||||
|
"esModuleInterop": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>MTG League</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light dark}
|
||||||
|
body{font-family:system-ui,sans-serif;max-width:900px;margin:1rem auto;padding:0 1rem}
|
||||||
|
table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #8884;padding:.3rem;text-align:left}
|
||||||
|
form.i{display:inline}input,select{max-width:100%}
|
||||||
|
</style>
|
||||||
|
<script nonce="<%= nonce %>">document.addEventListener("submit",e=>{const m=e.target.dataset.confirm;if(m&&!confirm(m))e.preventDefault()})</script>
|
||||||
|
<nav><a href="/">Leagues</a> ·
|
||||||
|
<% if (admin) { %><b>admin</b> <form class="i" method="post" action="/logout"><button>Logout</button></form>
|
||||||
|
<% } else { %><a href="/login">Admin login</a><% } %></nav>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<%- include('head') %>
|
||||||
|
<h1>MTG Leagues</h1>
|
||||||
|
<ul><% leagues.forEach(l => { %><li><a href="/league/<%= l.id %>"><%= l.name %></a> (best <%= l.best_n %> sessions count)</li><% }) %></ul>
|
||||||
|
<% if (admin) { %>
|
||||||
|
<h2>New league</h2>
|
||||||
|
<form method="post" action="/admin/league"><input name="name" placeholder="Name" required>
|
||||||
|
<input name="best_n" type="number" min="1" value="6"> best sessions count <button>Create</button></form>
|
||||||
|
<% } %>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<%- include('head') %>
|
||||||
|
<h1><%= lg.name %></h1>
|
||||||
|
<h2>Leaderboard <small>(best <%= lg.best_n %> sessions)</small></h2>
|
||||||
|
<table><tr><th>#</th><th>Player</th><th>Counted</th><th>All sessions</th><th>Played</th><th>W-D-L</th></tr>
|
||||||
|
<% rows.forEach((r, i) => { %><tr><td><%= i + 1 %></td><td><%= r.name %></td><td><b><%= r.score %></b></td><td><%= r.total %></td>
|
||||||
|
<td><%= r.sessions %></td><td><%= r.rec ? r.rec.join('-') : '–' %></td></tr><% }) %></table>
|
||||||
|
<h2>Sessions</h2>
|
||||||
|
<ul><% sessions.forEach(g => { %><li><a href="/session/<%= g.id %>">Session <%= g.number %> – <%= g.date %></a></li><% }) %></ul>
|
||||||
|
<% if (admin) { %><form method="post" action="/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %>
|
||||||
|
<h2>Deck performance <small>(match W-D-L)</small></h2>
|
||||||
|
<table><tr><th>Deck</th><th>W-D-L</th></tr><% decks.forEach(([d, r]) => { %><tr><td><%= d %></td><td><%= r.join('-') %></td></tr><% }) %></table>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<%- include('head') %>
|
||||||
|
<h1>Admin login</h1><% if (error) { %><p><%= error %></p><% } %>
|
||||||
|
<form method="post"><input type="password" name="password" autofocus> <button>Login</button></form>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<%- include('head') %>
|
||||||
|
<h1><%= lg.name %> – Session <%= gs.number %> (<%= gs.date %>)</h1>
|
||||||
|
<p><a href="/league/<%= lg.id %>">← Standings</a></p>
|
||||||
|
<% rounds.forEach(r => { %><h2>Round <%= r.round %></h2><table>
|
||||||
|
<% r.matches.forEach(m => { %><tr>
|
||||||
|
<td><%= m.p1 %> <small>(<%= m.p1_deck || '?' %>)</small></td>
|
||||||
|
<td><% if (m.p2) { %><%= m.p2 %> <small>(<%= m.p2_deck || '?' %>)</small><% } else { %><i>BYE</i><% } %></td>
|
||||||
|
<td><b><%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %></b></td></tr>
|
||||||
|
<% }) %></table><% }) %>
|
||||||
|
<% if (!rounds.length && !results.length) { %><p>No results yet.</p><% } %>
|
||||||
|
<% if (results.length) { %><h2>Standings <small>(imported)</small></h2>
|
||||||
|
<form method="post" action="/admin/session/<%= gs.id %>/decks">
|
||||||
|
<table><tr><th>Player</th><th>Points</th><th>W-D-L</th><th>Deck</th></tr>
|
||||||
|
<% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td>
|
||||||
|
<td><% if (admin) { %><input name="deck_<%= r.id %>" value="<%= r.deck || '' %>" list="decks"><% } else { %><%= r.deck || '' %><% } %></td></tr><% }) %></table>
|
||||||
|
<% if (admin) { %><datalist id="decks"><% known.forEach(k => { %><option value="<%= k %>"><% }) %></datalist><button>Save decks</button><% } %>
|
||||||
|
</form>
|
||||||
|
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
|
||||||
|
<% if (admin) { %>
|
||||||
|
<h2>Import Melee standings export</h2>
|
||||||
|
<p>Upload the CSV/JSON from Melee's Standings tab (or paste it). Saving replaces this session's imported standings but keeps decks already entered.</p>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="/admin/session/<%= gs.id %>/import-standings">
|
||||||
|
<input type="file" name="file" accept=".csv,.json,.txt"><br>
|
||||||
|
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import standings</button></form>
|
||||||
|
<h2>Import matches by hand</h2>
|
||||||
|
<p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye.
|
||||||
|
Comma- or tab-separated. Saving <b>replaces</b> this session's matches.</p>
|
||||||
|
<form method="post" action="/admin/session/<%= gs.id %>/import">
|
||||||
|
<textarea name="data" rows="12" style="width:100%;font-family:monospace"><%= csvtext %></textarea><br><button>Save matches</button></form>
|
||||||
|
<form method="post" action="/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form>
|
||||||
|
<% } %>
|
||||||
Reference in new issue
Block a user