Files
derekandClaude Opus 5.5 985c85bcef 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>
2026-10-28 16:58:24 +00:00

104 lines
5.5 KiB
TypeScript

// Parse a standings export (Melee JSON, or a generic CSV/JSON) into rows. No dependencies.
export type Wdl = [number, number, number];
export type Row = { name: string; points: number; deck: string; wdl: Wdl | null };
type Rec = Record<string, any>;
export class InputError extends Error {}
const PLAYER = ["player", "playername", "username", "playerusername", "name", "displayname", "team", "teamname"];
const POINTS = ["points", "matchpoints", "totalpoints", "pts"];
const DECK = ["deck", "deckname", "archetype", "decklistname", "decklistarchetype"];
const norm = (k: string) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
const flat = (o: Rec, prefix = ""): Rec =>
Object.entries(o).reduce<Rec>((out, [k, v]) =>
v && typeof v === "object" && !Array.isArray(v) ? { ...out, ...flat(v, prefix + k) } : { ...out, [prefix + k]: v }, {});
export function csvRows(text: string, delim: string): string[][] {
const rows: string[][] = [];
let row: string[] = [], cell = "", quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch !== '"') cell += ch;
else if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false;
} else if (ch === '"' && cell === "") quoted = true;
else if (ch === delim) { row.push(cell); cell = ""; }
else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(cell); rows.push(row); row = []; cell = "";
} else cell += ch;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows.map((r) => r.map((c) => c.trim())).filter((r) => r.some((c) => c !== ""));
}
export function parseTable(input: string): Rec[] {
const text = input.trim().replace(/^\uFEFF/, "");
if (!text) return [];
if (text[0] === "[" || text[0] === "{") {
let j: any = JSON.parse(text);
while (j && !Array.isArray(j) && typeof j === "object") j = Object.values(j).find(Array.isArray); // unwrap {"data": [...]}
return Array.isArray(j) ? j.filter((r) => r && typeof r === "object") : [];
}
const first = text.split(/\r?\n/)[0];
const count = (c: string) => first.split(c).length - 1;
const delim = count("\t") ? "\t" : count(";") > count(",") ? ";" : ",";
const [head, ...body] = csvRows(text, delim);
return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""])));
}
// 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 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}`);
return [{ name, points: nums[0], deck: "", wdl: [nums[1], nums[2], nums[3]] }];
});
}
export function extract(input: Rec[]): Row[] {
if (!input.length) throw new InputError("No rows found in that file");
if ("Team" in input[0] && "MatchWins" in input[0] && "RoundNumber" in input[0]) return melee(input);
const rows = input.map((r) => flat(r));
const by = new Map(Object.keys(rows[0]).map((h) => [norm(h), h]));
const col = (aliases: string[]) => by.get(aliases.find((a) => by.has(a)) ?? "");
const [pc, sc, dc] = [col(PLAYER), col(POINTS), col(DECK)];
if (!pc || !sc) throw new InputError("Couldn't find the player/points columns. Headers in the file: " + [...by.values()].join(", "));
return rows.flatMap((r): Row[] => {
const name = String(r[pc] ?? "").trim();
if (!name) return [];
const pts = Number(String(r[sc]).trim());
if (!Number.isFinite(pts)) throw new InputError(`Bad points value for ${name}: ${r[sc]}`);
if (pts < 0 || pts > 1e6) throw new InputError(`Bad points value for ${name}: ${r[sc]}`);
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;
}