Import Melee matches exports, add tests and local podman stack

- Parse Melee matches exports (one round per file, byes, null GameWins);
  one import form detects matches vs standings exports. Re-importing a
  round replaces just that round and keeps entered decks.
- Per-player deck entry for sessions scored from matches.
- node:test suite with anonymised fixtures; lockfile.
- scripts/local.sh runs the compose stack under podman with its own
  .env.local, project name and volume.
- README and CLAUDE.md docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
derekandClaude Opus 5.5 committed 2026-10-28 16:58:24 +00:00
1 parent a7f440c658
commit 985c85bcef
15 files changed
+2450 -27

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
node_modules
.git
.env
sampledata
test
.env.local
+2 -1
View File
@@ -1,5 +1,6 @@
# generate with: openssl rand -hex 32
# generate with: openssl rand -hex 16
DB_PASSWORD=
# generate with: openssl rand -hex 32
SECRET_KEY=
# 10+ characters
ADMIN_PASSWORD=
+4
View File
@@ -1,2 +1,6 @@
node_modules
.env
# Melee exports contain personal details (Discord names, DCI numbers)
matches-export-*.json
Standings-*.json
.env.local
+60
View File
@@ -0,0 +1,60 @@
# MTG league tracker
Small web app for a Magic: The Gathering league that runs its nights on melee.gg. Melee stays the source of truth
for pairings and results; this app is a dashboard (leaderboard, session results, deck stats) fed by Melee exports.
Hosted at home (Proxmox VM, Docker, Portainer stack deployed from Git), exposed through Caddy on a VPS over Tailscale.
## Stack
Node + TypeScript run directly with `tsx` (no build step), Express 5, server-rendered EJS, Postgres via `pg` with
plain parameterised SQL (no ORM). Tables are created on startup in `src/db.ts`. There is no migration tool yet:
add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migration tool when the schema changes.
## Layout
- `src/server.ts` routes, auth, security middleware, import handlers
- `src/tables.ts` pure parsers: Melee matches export, Melee standings export, generic CSV/JSON (no deps)
- `src/standings.ts` pure leaderboard + deck-stats logic
- `src/db.ts` pool, `tx()` helper, schema
- `views/*.ejs` five pages. Every link/form action is prefixed with `<%= base %>` (BASE_PATH support)
- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks.
## Rules and decisions
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
Leaderboard = sum of each player's best `league.best_n` session scores; tiebreak = total of all sessions, then name.
- A session with match rows is scored from them; otherwise from imported standings (`session_results`).
Standings imports have W-D-L but no opponents, so the leaderboard shows "–" W-D-L for players with such a session.
- Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username),
matched case-insensitively and created on first sight. Spelling differences create duplicate players.
- No top cut will ever be used, so the standings importer takes the last round of the export.
- Decks are free text, stored per player per session (Melee exports contain no deck names). `known` decks feed a datalist
to avoid "Burn"/"burn" duplicates. Deck stats count whole-night results against the deck.
- 1-on-1 matches only. Unreported matches (`HasResult` false) are skipped.
## Melee export shapes (observed)
- Standings export: array of rows, one snapshot per round (`RoundNumber`), each with `Team.Players[]`, `Points`,
`MatchWins/MatchDraws/MatchLosses`. Use the final round only.
- Matches export: array of matches with `Competitors[]` (sorted by `SortOrder`), `GameWinsAndGameByes` (always
filled; `GameWins` is null for 0), `GameDraws`, `HasResult`, `RoundNumber`. One round per file; a bye is a single competitor.
- Melee only keeps organiser export data for ~60 days. The data API needs approval from contact@melee.gg, so it
was deliberately not used; a future automation could use it (credentials must stay server side).
- Real exports contain personal data (Discord names, DCI numbers) and are gitignored. Use `test/fixtures` instead.
## Security measures already in place
Parameterised SQL only; EJS escaping everywhere; admin-only routes behind `admin` middleware; strict CSP with a nonce
(no inline handlers: use `data-confirm`); same-origin check on non-GET requests; login rate limit (10 failures / 15 min / IP);
startup refuses weak `SECRET_KEY` (<32) or `ADMIN_PASSWORD` (<10); container runs as `node`; port bound to `BIND_ADDR`.
Admin cookie is a stateless signed cookie (logout does not revoke it; rotate `SECRET_KEY` to invalidate).
Leaderboard pages are public and show real names; protect with Caddy `basic_auth` if that is a concern.
## Config (env)
`DB_PASSWORD`, `SECRET_KEY`, `ADMIN_PASSWORD`, `COOKIE_SECURE`, `BIND_ADDR` (Tailscale IP of the VM), `HOST_PORT` (default 8888),
`BASE_PATH` (sub-path like `/mtg`, no trailing slash; Caddy must use `handle_path` so it strips the prefix).
## Status
Verified locally (2026-10-28): `npm run check`, `npm test`, and the compose stack under podman (`scripts/local.sh up`)
with real Melee exports: login, CSRF block, league/session creation, matches + standings import, decks, all pages, API.
Hosted at https://derekc.xyz/mtgleague (`BASE_PATH=/mtgleague`); the user confirmed `BASE_PATH` and `COOKIE_SECURE=true`
login work behind Caddy, and the public pages' security headers and prefixed links were checked through the proxy.
## Ideas for automation
Scripted import of a Melee export directory; a CLI/API-token route for imports instead of the admin form; nightly `pg_dump`;
GitOps redeploy in Portainer; per-round deck changes; merge/rename players; Melee API sync if access is ever granted.
+31
View File
@@ -0,0 +1,31 @@
# MTG league tracker
Leaderboard and results dashboard for a Magic league run on melee.gg. See `CLAUDE.md` for design notes.
## Run
```
cp .env.example .env # fill in DB_PASSWORD, SECRET_KEY (openssl rand -hex 32), ADMIN_PASSWORD (10+ chars)
docker compose up -d --build
```
Listens on `${BIND_ADDR:-127.0.0.1}:${HOST_PORT:-8888}`. Put a reverse proxy in front and set `COOKIE_SECURE=true` once on https.
Sub-path hosting: set `BASE_PATH=/mtg` and proxy with Caddy `handle_path /mtg/*`.
## Develop
```
npm install
npm test # parser + standings tests
npm run check # type-check
DATABASE_URL=postgres://... SECRET_KEY=... ADMIN_PASSWORD=... npm run dev
```
Whole stack locally with podman (own `.env.local` with generated secrets, own project/volume, port 8889):
```
scripts/local.sh up # build + start, prints URL and admin password
scripts/local.sh logs | ps | down
scripts/local.sh reset # down and delete the local database
```
Needs `podman compose` (podman 4.7+ with docker-compose or podman-compose) and the user socket:
`systemctl --user enable --now podman.socket`.
## Using it
Admin login, create a league (set how many top sessions count), add a session per night, then upload the Melee
matches export (one per round) or standings export on the session page, and set each player's deck.
+1984
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": {
"start": "node --import tsx src/server.ts",
"dev": "node --import tsx --watch src/server.ts",
"check": "tsc"
"check": "tsc",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"cookie-session": "^2.1.0",
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Run the docker compose stack locally with podman, isolated from the real .env and data.
# usage: scripts/local.sh up | down | reset | logs | ps
# First run writes .env.local (random secrets, bound to 127.0.0.1). Set BASE_PATH or COOKIE_SECURE there to test them.
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ ! -f .env.local ]]; then
cat > .env.local <<EOF
DB_PASSWORD=$(openssl rand -hex 16)
SECRET_KEY=$(openssl rand -hex 32)
ADMIN_PASSWORD=local-$(openssl rand -hex 6)
COOKIE_SECURE=false
BIND_ADDR=127.0.0.1
HOST_PORT=8889
BASE_PATH=
EOF
echo "wrote .env.local"
fi
compose() { podman compose --env-file .env.local -p mtg-local "$@"; }
case "${1:-up}" in
up)
compose up -d --build --wait
set -a; source .env.local; set +a
echo "http://127.0.0.1:${HOST_PORT}${BASE_PATH}/ admin password: ${ADMIN_PASSWORD}"
;;
down) compose down ;;
reset) compose down -v ;; # also deletes the local database volume
logs) compose logs -f ;;
ps) compose ps ;;
*) echo "usage: $0 up|down|reset|logs|ps" >&2; exit 1 ;;
esac
+55 -15
View File
@@ -5,7 +5,7 @@ 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";
import { csvRows, extract, extractMatches, isMatchExport, parseTable, type Match, type Row } from "./tables.ts";
const { SECRET_KEY, ADMIN_PASSWORD } = process.env;
if (!SECRET_KEY || !ADMIN_PASSWORD) throw new Error("Set SECRET_KEY and ADMIN_PASSWORD");
@@ -132,7 +132,12 @@ app.get("/session/:id", async (req, res) => {
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 });
// one deck per player per night, stored on each of their match rows
const matchPlayers = await q(
`SELECT p.id, p.name, MAX(x.deck) AS deck FROM (SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1
UNION ALL SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_id IS NOT NULL) x
JOIN players p ON p.id=x.pid GROUP BY p.id, p.name ORDER BY p.name`, [sid]);
res.render("session", { gs, lg, rounds, csvtext, results, known, matchPlayers });
});
// ---------- auth ----------
@@ -204,23 +209,58 @@ app.post("/admin/session/:id/import", admin, async (req, res) => {
res.redirect(303, BASE + `/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) => {
// Import a Melee matches export (replaces only the rounds in the file) or standings export (replaces the
// session's imported standings). Uploaded or pasted; decks already entered are kept.
app.post("/admin/session/:id/import-melee", 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); }
let parsed: { matches: Match[] } | { rows: Row[] };
try {
const table = parseTable(text);
parsed = isMatchExport(table) ? { matches: extractMatches(table) } : { rows: extract(table) };
} catch (e) { throw new HttpError(400, (e as Error).message); }
await ("matches" in parsed ? importMatches(sid, parsed.matches) : importStandings(sid, parsed.rows));
res.redirect(303, BASE + `/session/${sid}`);
});
const importMatches = (sid: number, matches: Match[]) => tx(async (c) => {
const old = new Map<number, string>((await c.query(
`SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1 AND p1_deck IS NOT NULL
UNION SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_deck IS NOT NULL`, [sid])).rows.map((r) => [r.pid, r.deck]));
const pid = await resolver(c);
await c.query("DELETE FROM matches WHERE session_id=$1 AND round = ANY($2::int[])", [sid, [...new Set(matches.map((m) => m.round))]]);
for (const m of matches) {
const [a, b] = [(await pid(m.a))!, await pid(m.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, m.round, a, b, old.get(a) ?? null, b == null ? null : old.get(b) ?? null, m.w1, m.w2, m.draws]);
}
});
const importStandings = (sid: number, rows: Row[]) => 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])]);
}
});
app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
const sid = idOf(req);
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])]);
for (const [k, v] of Object.entries(req.body)) {
const m = /^mdeck_(\d+)$/.exec(k);
if (!m) continue;
const deck = String(v).trim() || null;
await c.query("UPDATE matches SET p1_deck=$1 WHERE session_id=$2 AND p1_id=$3", [deck, sid, Number(m[1])]);
await c.query("UPDATE matches SET p2_deck=$1 WHERE session_id=$2 AND p2_id=$3", [deck, sid, Number(m[1])]);
}
});
res.redirect(303, BASE + `/session/${sid}`);
+27 -4
View File
@@ -47,13 +47,17 @@ export function parseTable(input: string): Rec[] {
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).
// Real names are used (falling back to display name, then username).
const playerName = (team: Rec | undefined) => {
const p = team?.Players?.[0] ?? {};
return String(p.Name || p.DisplayName || p.Username || "").trim();
};
// Melee's standings export holds one snapshot per round, so use the final round.
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();
const name = playerName(r.Team);
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}`);
@@ -78,3 +82,22 @@ export function extract(input: Rec[]): Row[] {
return [{ name, points: Math.round(pts), deck: dc ? String(r[dc] ?? "").trim() : "", wdl: null }];
});
}
// Melee's matches export: one round per file, a bye is a single competitor. GameWins is null for 0, so read
// GameWinsAndGameByes. Unreported matches are skipped.
export type Match = { round: number; a: string; b: string | null; w1: number; w2: number; draws: number };
export const isMatchExport = (rows: Rec[]) => rows.length > 0 && Array.isArray(rows[0].Competitors) && "RoundNumber" in rows[0];
export function extractMatches(rows: Rec[]): Match[] {
const out = rows.filter((m) => m.HasResult).map((m): Match => {
const cs = [...(m.Competitors ?? [])].sort((x, y) => (Number(x.SortOrder) || 0) - (Number(y.SortOrder) || 0));
const [a, b] = cs.map((c) => playerName(c.Team));
if (!a || cs.length > 2 || (cs.length === 2 && !b)) throw new InputError(`Round ${m.RoundNumber}: only 1-on-1 matches with named players are supported`);
const nums = [m.RoundNumber, cs[0].GameWinsAndGameByes, cs[1]?.GameWinsAndGameByes ?? 0, m.GameDraws ?? 0].map(Number);
if (!nums.every((n) => Number.isInteger(n) && n >= 0 && n <= 99)) throw new InputError(`Bad numbers in the match for ${a}`);
const [round, w1, w2, draws] = nums;
return b ? { round, a, b, w1, w2, draws } : { round, a, b: null, w1: 2, w2: 0, draws: 0 };
});
if (!out.length) throw new InputError("No completed matches in that file");
return out;
}
+103
View File
@@ -0,0 +1,103 @@
[
{
"Competitors": [
{
"Team": {
"Players": [
{
"Name": "Eve Evans",
"DisplayName": "eve",
"Username": "eve"
}
]
},
"SortOrder": 0,
"GameByes": 2,
"GameWins": null,
"GameWinsAndGameByes": 2,
"Decklists": []
}
],
"RoundNumber": 4,
"GameDraws": 0,
"HasResult": true
},
{
"Competitors": [
{
"Team": {
"Players": [
{
"Name": "Alice Archer",
"DisplayName": "alice",
"Username": "alice"
}
]
},
"SortOrder": 0,
"GameByes": null,
"GameWins": 2,
"GameWinsAndGameByes": 2,
"Decklists": []
},
{
"Team": {
"Players": [
{
"Name": "Bob Baker",
"DisplayName": "bobb",
"Username": "bobb"
}
]
},
"SortOrder": 1,
"GameByes": null,
"GameWins": null,
"GameWinsAndGameByes": 0,
"Decklists": []
}
],
"RoundNumber": 4,
"GameDraws": 0,
"HasResult": true
},
{
"Competitors": [
{
"Team": {
"Players": [
{
"Name": "Cara Chen",
"DisplayName": "cara",
"Username": "cara"
}
]
},
"SortOrder": 0,
"GameByes": null,
"GameWins": 1,
"GameWinsAndGameByes": 1,
"Decklists": []
},
{
"Team": {
"Players": [
{
"Name": "Dan Doyle",
"DisplayName": "dand",
"Username": "dand"
}
]
},
"SortOrder": 1,
"GameByes": null,
"GameWins": 2,
"GameWinsAndGameByes": 2,
"Decklists": []
}
],
"RoundNumber": 4,
"GameDraws": 0,
"HasResult": true
}
]
+66
View File
@@ -0,0 +1,66 @@
[
{
"Team": {
"Players": [
{
"Name": "Alice Archer",
"DisplayName": "alice",
"Username": "alice"
}
]
},
"RoundNumber": 1,
"Points": 3,
"MatchWins": 1,
"MatchDraws": 0,
"MatchLosses": 0
},
{
"Team": {
"Players": [
{
"Name": "Bob Baker",
"DisplayName": "bobb",
"Username": "bobb"
}
]
},
"RoundNumber": 1,
"Points": 0,
"MatchWins": 0,
"MatchDraws": 0,
"MatchLosses": 1
},
{
"Team": {
"Players": [
{
"Name": "Alice Archer",
"DisplayName": "alice",
"Username": "alice"
}
]
},
"RoundNumber": 2,
"Points": 6,
"MatchWins": 2,
"MatchDraws": 0,
"MatchLosses": 0
},
{
"Team": {
"Players": [
{
"Name": "Bob Baker",
"DisplayName": "bobb",
"Username": "bobb"
}
]
},
"RoundNumber": 2,
"Points": 1,
"MatchWins": 0,
"MatchDraws": 1,
"MatchLosses": 1
}
]
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { computeStandings } from "../src/standings.ts";
import { csvRows, extract, extractMatches, isMatchExport, parseTable } from "../src/tables.ts";
const fixture = (f: string) => parseTable(readFileSync(new URL(`./fixtures/${f}`, import.meta.url), "utf8"));
test("matches export: detects shape, reads byes and null GameWins", () => {
const rows = fixture("matches.json");
assert.ok(isMatchExport(rows));
assert.deepEqual(extractMatches(rows), [
{ round: 4, a: "Eve Evans", b: null, w1: 2, w2: 0, draws: 0 },
{ round: 4, a: "Alice Archer", b: "Bob Baker", w1: 2, w2: 0, draws: 0 },
{ round: 4, a: "Cara Chen", b: "Dan Doyle", w1: 1, w2: 2, draws: 0 },
]);
});
test("matches export: unreported matches are skipped, empty file rejected", () => {
assert.throws(() => extractMatches([{ HasResult: false, Competitors: [] }]), /No completed matches/);
});
test("standings export: only the final round is used, real names", () => {
const rows = fixture("standings.json");
assert.ok(!isMatchExport(rows));
assert.deepEqual(extract(rows).map((r) => [r.name, r.points, r.wdl]), [
["Alice Archer", 6, [2, 0, 0]],
["Bob Baker", 1, [0, 1, 1]],
]);
});
test("generic CSV: quoted commas, header aliases, helpful error", () => {
assert.deepEqual(extract(parseTable('Rank,Player,Points,Deck\n1,"Bob, Jr",6,Elves\n')),
[{ name: "Bob, Jr", points: 6, deck: "Elves", wdl: null }]);
assert.throws(() => extract(parseTable("Rank,Foo\n1,x")), /Headers in the file: Rank, Foo/);
assert.deepEqual(csvRows('1,"a ""q"" b",x', ","), [["1", 'a "q" b', "x"]]);
});
test("rejects non-numeric scores in uploaded JSON", () => {
const bad = [{ Team: { Players: [{ Name: "A" }] }, RoundNumber: 1, Points: 3, MatchWins: "x", MatchDraws: 0, MatchLosses: 0 }];
assert.throws(() => extract(bad), /Bad numbers/);
});
test("leaderboard: best-N sessions count, bye is a win, W-D-L unknown for points-only imports", () => {
const names = new Map([[1, "Ann"], [2, "Bob"], [3, "Cy"]]);
const ms = [
{ session_id: 1, p1_id: 1, p2_id: 2, p1_deck: "Burn", p2_deck: "Elves", p1_wins: 2, p2_wins: 1 },
{ session_id: 1, p1_id: 3, p2_id: null, p1_deck: null, p2_deck: null, p1_wins: 2, p2_wins: 0 },
];
const rs = [
{ session_id: 2, player_id: 1, points: 9, deck: "Burn", wins: 3, draws: 0, losses: 0 },
{ session_id: 2, player_id: 2, points: 4, deck: null, wins: null, draws: null, losses: null },
];
const { rows, decks } = computeStandings(ms, rs, names, 1);
assert.deepEqual(rows.map((r) => [r.name, r.score, r.total, r.rec]), [
["Ann", 9, 12, [4, 0, 0]], // best 1 of {3, 9}
["Bob", 4, 4, null],
["Cy", 3, 3, [1, 0, 0]],
]);
assert.deepEqual(decks, [["Burn", [4, 0, 0]], ["Elves", [0, 0, 1]]]);
});
test("a session with match rows ignores its imported standings", () => {
const names = new Map([[1, "Ann"], [2, "Bob"]]);
const ms = [{ session_id: 1, p1_id: 1, p2_id: 2, p1_deck: null, p2_deck: null, p1_wins: 2, p2_wins: 0 }];
const rs = [{ session_id: 1, player_id: 2, points: 99, deck: null, wins: 9, draws: 0, losses: 0 }];
assert.equal(computeStandings(ms, rs, names, 6).rows.find((r) => r.name === "Bob")!.score, 0);
});
+1 -1
View File
@@ -4,5 +4,5 @@
"strict": true, "noEmit": true, "allowImportingTsExtensions": true,
"esModuleInterop": true, "skipLibCheck": true
},
"include": ["src"]
"include": ["src", "test"]
}
+11 -5
View File
@@ -1,4 +1,5 @@
<%- include('head') %>
<% if (admin) { %><datalist id="decks"><% known.forEach(k => { %><option value="<%= k %>"><% }) %></datalist><% } %>
<h1><%= lg.name %> – Session <%= gs.number %> (<%= gs.date %>)</h1>
<p><a href="<%= base %>/league/<%= lg.id %>">← Standings</a></p>
<% rounds.forEach(r => { %><h2>Round <%= r.round %></h2><table>
@@ -8,20 +9,25 @@
<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 (admin && matchPlayers.length) { %><h2>Decks played</h2>
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/match-decks"><table><tr><th>Player</th><th>Deck</th></tr>
<% matchPlayers.forEach(p => { %><tr><td><%= p.name %></td><td><input name="mdeck_<%= p.id %>" value="<%= p.deck || '' %>" list="decks"></td></tr><% }) %></table>
<button>Save decks</button></form><% } %>
<% if (results.length) { %><h2>Standings <small>(imported)</small></h2>
<form method="post" action="<%= base %>/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><% } %>
<% if (admin) { %><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="<%= base %>/admin/session/<%= gs.id %>/import-standings">
<h2>Import Melee export</h2>
<p>Upload a <b>matches</b> or <b>standings</b> export from Melee (JSON or CSV), or paste it. A matches export holds one round, so import each round in turn;
re-importing a round replaces just that round. Decks you've entered are kept. If a session has matches, they are used for scoring.</p>
<form method="post" enctype="multipart/form-data" action="<%= base %>/admin/session/<%= gs.id %>/import-melee">
<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>
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import</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>