- 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>
104 lines
5.5 KiB
TypeScript
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;
|
|
}
|