// 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; 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((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; }