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:
1 parent
a7f440c658
commit
985c85bcef
15 files changed
+2450
-27
No files matched your search
+55
-15
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user