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') %>

<%= lg.name %>

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

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

<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %> diff --git a/views/session.ejs b/views/session.ejs index dff341b..06929ba 100644 --- a/views/session.ejs +++ b/views/session.ejs @@ -9,17 +9,13 @@ <% }) %>
#PlayerCountedAll sessionsPlayedW-D-LOMW%GW%OGW%
<%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %>
<% }) %> <% if (!rounds.length && !results.length) { %>

No results yet.

<% } %> -<% if (admin && matchPlayers.length) { %>

Decks played

-
-<% matchPlayers.forEach(p => { %><% }) %>
PlayerDeck
<%= p.name %>
+<% if (admin && players.length) { %>

Decks played

+ +<% players.forEach(p => { %><% }) %>
PlayerDeck
<%= p.name %>
<% } %> <% if (results.length) { %>

Standings (imported)

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