Initial Commit

This commit is contained in:
derek committed 2026-10-28 12:47:05 +00:00
commit ef1c188807
16 files changed
+570

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// 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] ?? ""])));
}
// 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).
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();
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 }];
});
}