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 <noreply@anthropic.com>
This commit is contained in:
derekandClaude Opus 5.5 committed 2026-10-28 17:20:45 +00:00
1 parent a99997b96d
commit 022d161934
6 files changed
+147 -36

No files matched your search

+4 -2
View File
@@ -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.
+1
View File
@@ -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):
+37 -25
View File
@@ -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<number, string | null>((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<string, number>((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<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 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<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 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_<player id>, 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");
Executable
+99
View File
@@ -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 '<h1>e2e renamed</h1>' "$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; }
+1
View File
@@ -1,5 +1,6 @@
<%- include('head') %>
<h1><%= lg.name %></h1>
<% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/rename"><input name="name" value="<%= lg.name %>" required> <button>Rename league</button></form><% } %>
<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><th>OMW%</th><th>GW%</th><th>OGW%</th></tr>
<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %>
+5 -9
View File
@@ -9,17 +9,13 @@
<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>
<% if (admin && players.length) { %><h2>Decks played</h2>
<form method="post" action="<%= base %>/admin/session/<%= gs.id %>/decks"><table><tr><th>Player</th><th>Deck</th></tr>
<% players.forEach(p => { %><tr><td><%= p.name %></td><td><input name="deck_<%= 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) { %><button>Save decks</button><% } %>
</form>
<% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td><td><%= r.deck || '' %></td></tr><% }) %></table>
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
<% if (admin) { %>
<h2>Import Melee export</h2>
@@ -31,7 +27,7 @@ If anything in the batch is invalid, nothing is imported. If a session has match
<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>
Comma- or tab-separated. Saving <b>replaces</b> this session's matches; a blank deck keeps the one already entered.</p>
<form method="post" action="<%= base %>/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="<%= base %>/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form>