From 022d161934ada1348bf4154213c78312c65d2dc3 Mon Sep 17 00:00:00 2001
From: Derek Choi
Date: Wed, 28 Oct 2026 17:20:45 +0000
Subject: [PATCH] One deck form per session; rename leagues; e2e test script
- A player's deck for a night is edited in a single form and written to
their match rows and their standings row, so the two can't disagree.
Matches, standings and hand imports all fill blank decks from the decks
already entered, whatever order the files are imported in. The imported
standings table shows decks read-only.
- Admins can rename a league.
- test/e2e.sh runs end-to-end checks against the local podman stack
(fixtures by default, or real exports: test/e2e.sh sampledata).
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 6 ++-
README.md | 1 +
src/server.ts | 62 +++++++++++++++++------------
test/e2e.sh | 99 +++++++++++++++++++++++++++++++++++++++++++++++
views/league.ejs | 1 +
views/session.ejs | 14 +++----
6 files changed, 147 insertions(+), 36 deletions(-)
create mode 100755 test/e2e.sh
diff --git a/CLAUDE.md b/CLAUDE.md
index e7301ef..949eaa7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
- `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.
+- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks. `test/e2e.sh [sampledata]`
+ runs end-to-end checks (imports, decks, hand import, league rename/delete) against `scripts/local.sh up`.
## Rules and decisions
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
@@ -27,7 +28,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
- 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
+- Decks are free text, one per player per session (Melee exports contain no deck names), edited in one form and kept
+ identical on the player's match rows and standings row; every import fills blanks from the decks already entered. `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.
diff --git a/README.md b/README.md
index 1a2c5e1..a41e270 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ Sub-path hosting: set `BASE_PATH=/mtg` and proxy with Caddy `handle_path /mtg/*`
npm install
npm test # parser + standings tests
npm run check # type-check
+scripts/local.sh up && test/e2e.sh [sampledata] # end-to-end checks against the local podman stack
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):
diff --git a/src/server.ts b/src/server.ts
index 7902fc3..652e55b 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -82,6 +82,16 @@ async function standingsFor(l: { id: number; best_n: number }) {
return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n);
}
+// Everyone in a session (from its matches and imported standings) with their deck. A deck is per player per night
+// and is kept identical on their match rows and their standings row.
+const SESSION_PLAYERS = `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
+ UNION ALL SELECT player_id, deck FROM session_results WHERE session_id=$1) x
+ JOIN players p ON p.id=x.pid GROUP BY p.id, p.name ORDER BY p.name`;
+const sessionDecks = async (c: Q, sid: number) =>
+ new Map((await c.query(SESSION_PLAYERS, [sid])).rows.map((r) => [r.id, r.deck]));
+
/** 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]));
@@ -132,12 +142,8 @@ 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);
- // 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 });
+ const players = await q(SESSION_PLAYERS, [sid]);
+ res.render("session", { gs, lg, rounds, csvtext, results, known, players });
});
// ---------- auth ----------
@@ -176,6 +182,14 @@ app.post("/admin/league/:id/session", admin, async (req, res) => {
res.redirect(303, BASE + `/session/${s.id}`);
});
+app.post("/admin/league/:id/rename", admin, async (req, res) => {
+ const name = String(req.body.name ?? "").trim();
+ if (!name) throw new HttpError(400, "Name required");
+ const [l] = await q("UPDATE leagues SET name=$1 WHERE id=$2 RETURNING id", [name, idOf(req)]);
+ if (!l) throw new HttpError(404, "Not found");
+ res.redirect(303, BASE + `/league/${l.id}`);
+});
+
app.post("/admin/league/:id/delete", admin, async (req, res) => {
const id = idOf(req);
await tx(async (c) => {
@@ -205,15 +219,22 @@ app.post("/admin/session/:id/import", admin, async (req, res) => {
return [{ rnd, a: c[1], da: c[2] || null, b: c[3], db: c[4] || null, w1, w2, dr }];
});
await tx(async (c) => {
+ const old = await sessionDecks(c, sid); // a blank deck keeps the one already entered
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;
+ const [a, b] = [(await pid(r.a))!, await pid(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]);
+ [sid, r.rnd, a, b, r.da ?? old.get(a) ?? null, b == null ? null : r.db ?? old.get(b) ?? null, bye ? 2 : r.w1, bye ? 0 : r.w2, bye ? 0 : r.dr]);
}
+ await c.query(
+ `UPDATE session_results r SET deck=m.deck FROM (SELECT pid, MAX(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) x GROUP BY pid) m
+ WHERE r.session_id=$1 AND r.player_id=m.pid AND m.deck IS NOT NULL`, [sid]);
});
res.redirect(303, BASE + `/session/${sid}`);
});
@@ -250,9 +271,7 @@ app.post("/admin/session/:id/import-melee", admin, upload.array("files", 50), as
});
async function importMatches(c: Q, sid: number, matches: Match[]) {
- const old = new Map((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 old = await sessionDecks(c, sid);
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) {
@@ -265,7 +284,7 @@ async function importMatches(c: Q, sid: number, matches: Match[]) {
}
async function importStandings(c: Q, sid: number, rows: Row[]) {
- 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 old = await sessionDecks(c, sid);
const pid = await resolver(c);
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
for (const r of rows) {
@@ -277,29 +296,22 @@ async function importStandings(c: Q, sid: number, rows: Row[]) {
}
}
-app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
+// One deck per player per night: deck_, written to their match rows and their standings row
+app.post("/admin/session/:id/decks", admin, async (req, res) => {
const sid = idOf(req);
await tx(async (c) => {
for (const [k, v] of Object.entries(req.body)) {
- const m = /^mdeck_(\d+)$/.exec(k);
- if (!m) continue;
+ const pid = Number(/^deck_(\d{1,10})$/.exec(k)?.[1]);
+ if (!pid || pid > 2 ** 31 - 1) 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])]);
+ await c.query("UPDATE matches SET p1_deck=$1 WHERE session_id=$2 AND p1_id=$3", [deck, sid, pid]);
+ await c.query("UPDATE matches SET p2_deck=$1 WHERE session_id=$2 AND p2_id=$3", [deck, sid, pid]);
+ await c.query("UPDATE session_results SET deck=$1 WHERE session_id=$2 AND player_id=$3", [deck, sid, pid]);
}
});
res.redirect(303, BASE + `/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, BASE + `/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");
diff --git a/test/e2e.sh b/test/e2e.sh
new file mode 100755
index 0000000..6a13ffd
--- /dev/null
+++ b/test/e2e.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# End-to-end checks against the local podman stack: imports, the single deck form, hand import, league rename/delete.
+# usage: scripts/local.sh up && test/e2e.sh [dir]
+# dir holds Melee exports named *matches*.json / *tandings*.json (default test/fixtures; real ones: test/e2e.sh sampledata).
+# Creates a throwaway league and deletes it again at the end.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+DIR=${1:-test/fixtures}
+set -a; source .env.local; set +a
+B=http://127.0.0.1:$HOST_PORT
+T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
+fails=0
+
+check() { if [[ "$2" == "$3" ]]; then echo "ok $1"; else echo "FAIL $1: got '$2', want '$3'"; fails=$((fails + 1)); fi; }
+req() { curl -s -b "$T/jar" -c "$T/jar" -o "$T/out" -w '%{http_code}' "$@"; }
+anon() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+db() { podman exec mtg-local-db-1 psql -U league -tAc "$1"; }
+files() { for f in "$DIR"/$1; do printf -- '-F\nfiles=@%s\n' "$f"; done; }
+mapfile -t MATCHES < <(files '*atches*.json')
+mapfile -t STANDINGS < <(files '*tandings*.json')
+decks_of() { # distinct decks of player $2 in session $1, across match rows and the standings row
+ db "SELECT string_agg(DISTINCT coalesce(d, '-'), ',') FROM (SELECT p1_deck d FROM matches WHERE session_id=$1 AND p1_id=$2
+ UNION ALL SELECT p2_deck FROM matches WHERE session_id=$1 AND p2_id=$2
+ UNION ALL SELECT deck FROM session_results WHERE session_id=$1 AND player_id=$2) x"
+}
+
+echo "== auth and leagues"
+check "admin route needs login" "$(anon -d name=x "$B/admin/league")" 403
+check "login" "$(req -d "password=$ADMIN_PASSWORD" "$B/login")" 303
+req -d name=e2e-league -d best_n=6 "$B/admin/league" >/dev/null
+LG=$(db "SELECT max(id) FROM leagues")
+check "rename needs login" "$(anon -d name=x "$B/admin/league/$LG/rename")" 403
+check "rename rejects empty name" "$(req -d 'name= ' "$B/admin/league/$LG/rename")" 400
+check "rename" "$(req -d 'name=e2e renamed' "$B/admin/league/$LG/rename")" 303
+check "renamed in db" "$(db "SELECT name FROM leagues WHERE id=$LG")" "e2e renamed"
+req "$B/league/$LG" >/dev/null; check "league page shows new name" "$(grep -c '
e2e renamed
' "$T/out")" 1
+check "rename unknown league" "$(req -d name=x "$B/admin/league/2147483647/rename")" 404
+
+echo "== session A: standings, then a deck, then matches"
+req -d date=2026-10-14 "$B/admin/league/$LG/session" >/dev/null; SA=$(db "SELECT max(id) FROM sessions")
+check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SA/import-melee")" 303
+P1=$(db "SELECT player_id FROM session_results r JOIN players p ON p.id=r.player_id WHERE session_id=$SA ORDER BY p.name LIMIT 1")
+check "deck form saves to standings" "$(req -d "deck_$P1=Burn" "$B/admin/session/$SA/decks")" 303
+check "standings deck" "$(decks_of "$SA" "$P1")" Burn
+check "import all matches at once" "$(req "${MATCHES[@]}" "$B/admin/session/$SA/import-melee")" 303
+check "matches picked up the standings deck" "$(decks_of "$SA" "$P1")" Burn
+req "$B/session/$SA" >/dev/null
+check "one deck form on the page" "$(grep -c '/decks"' "$T/out")" 1
+check "one deck input per session player" "$(grep -o 'name="deck_' "$T/out" | wc -l)" "$(db "SELECT count(*) FROM ($(sed 's/\$1/'"$SA"'/g' <<<"SELECT pid FROM (SELECT p1_id pid FROM matches WHERE session_id=\$1 UNION SELECT p2_id FROM matches WHERE session_id=\$1 AND p2_id IS NOT NULL UNION SELECT player_id FROM session_results WHERE session_id=\$1) x")) y")"
+check "deck form saves to matches and standings" "$(req -d "deck_$P1=Control" "$B/admin/session/$SA/decks")" 303
+check "both updated" "$(decks_of "$SA" "$P1")" Control
+check "clearing a deck" "$(req -d "deck_$P1=" "$B/admin/session/$SA/decks")" 303
+check "cleared everywhere" "$(decks_of "$SA" "$P1")" -
+req -d "deck_$P1=Control" "$B/admin/session/$SA/decks" >/dev/null
+
+echo "== session A: hand import"
+csv() { db "COPY (SELECT m.round, a.name, CASE WHEN m.p1_id=$P1 THEN '$1' ELSE '' END, coalesce(b.name, ''),
+ CASE WHEN m.p2_id=$P1 THEN '$1' ELSE '' END, m.p1_wins, m.p2_wins, m.draws 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=$SA ORDER BY m.round, m.id) TO STDOUT WITH CSV"; }
+before=$(db "SELECT count(*) FROM matches WHERE session_id=$SA")
+check "hand import with blank decks" "$(req --data-urlencode "data=$(csv '')" "$B/admin/session/$SA/import")" 303
+check "match count unchanged" "$(db "SELECT count(*) FROM matches WHERE session_id=$SA")" "$before"
+check "blank keeps the entered deck" "$(decks_of "$SA" "$P1")" Control
+check "hand import with a new deck" "$(req --data-urlencode "data=$(csv Elves)" "$B/admin/session/$SA/import")" 303
+check "new deck reaches standings too" "$(decks_of "$SA" "$P1")" Elves
+
+echo "== session B: matches, then a deck, then standings"
+req -d date=2026-10-15 "$B/admin/league/$LG/session" >/dev/null; SB=$(db "SELECT max(id) FROM sessions")
+check "import matches" "$(req "${MATCHES[@]}" "$B/admin/session/$SB/import-melee")" 303
+P2=$(db "SELECT p1_id FROM matches WHERE session_id=$SB AND p2_id IS NOT NULL ORDER BY id LIMIT 1")
+check "deck on matches" "$(req -d "deck_$P2=Elves" "$B/admin/session/$SB/decks")" 303
+check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SB/import-melee")" 303
+if [[ -n "$(db "SELECT 1 FROM session_results WHERE session_id=$SB AND player_id=$P2")" ]]; then
+ check "standings picked up the match deck" "$(decks_of "$SB" "$P2")" Elves
+else echo "skip standings deck check: player not in the standings export"; fi
+
+echo "== scores"
+# only meaningful when the matches exports cover every round of the standings export
+python3 -I - "$DIR" "$(curl -s "$B/api/league/$LG/leaderboard")" "$(db "SELECT json_agg(json_build_object('name', p.name, 'points', r.points))
+ FROM session_results r JOIN players p ON p.id=r.player_id WHERE r.session_id=$SA")" <<'EOF' || fails=$((fails + 1))
+import glob, json, sys
+d, board, final = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
+rounds = {m["RoundNumber"] for f in glob.glob(d + "/*atches*.json") for m in json.load(open(f))}
+last = max(r["RoundNumber"] for f in glob.glob(d + "/*tandings*.json") for r in json.load(open(f)))
+if rounds != set(range(1, last + 1)):
+ print("skip leaderboard check: matches exports don't cover rounds 1 to", last); sys.exit(0)
+got = {r["name"]: r["total"] / 2 for r in board} # sessions A and B hold the same night
+bad = [f"{r['name']} {got.get(r['name'])} != {r['points']}" for r in final if got.get(r["name"]) != r["points"]]
+if bad: print("FAIL leaderboard vs Melee standings:", bad); sys.exit(1)
+print("ok leaderboard matches Melee's points for", len(final), "players")
+EOF
+
+echo "== delete league"
+check "delete league" "$(req -X POST "$B/admin/league/$LG/delete")" 303
+check "league gone" "$(db "SELECT count(*) FROM leagues WHERE id=$LG")" 0
+check "its sessions gone" "$(db "SELECT count(*) FROM sessions WHERE id IN ($SA, $SB)")" 0
+check "its matches gone" "$(db "SELECT count(*) FROM matches WHERE session_id IN ($SA, $SB)")" 0
+
+echo; [[ $fails == 0 ]] && echo "all e2e checks passed" || { echo "$fails e2e check(s) failed"; exit 1; }
diff --git a/views/league.ejs b/views/league.ejs
index fea0e03..5e0a2d9 100644
--- a/views/league.ejs
+++ b/views/league.ejs
@@ -1,5 +1,6 @@
<%- include('head') %>
<% }) %>
<% if (!rounds.length && !results.length) { %>
No results yet.
<% } %>
-<% if (admin && matchPlayers.length) { %>
Decks played
-<% } %>
<% if (results.length) { %>
Standings (imported)
-
+<% results.forEach(r => { %>
<%= r.name %>
<%= r.points %>
<%= r.rec %>
<%= r.deck || '' %>
<% }) %>
If this session also has match rows, those are used for scoring instead.
<% } %>
<% if (admin) { %>
Import Melee export
@@ -31,7 +27,7 @@ If anything in the batch is invalid, nothing is imported. If a session has match
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.
+Comma- or tab-separated. Saving replaces this session's matches; a blank deck keeps the one already entered.