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

+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;
}