Break leaderboard ties with Melee's tiebreakers
After total of all sessions, ties fall to OMW%, GW% and OGW% over all the league's matches (bye = 2-0 win, not an opponent; opponents floored at 1/3), then name. Verified identical to Melee's order and percentages on a real night. The leaderboard shows the three percentages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e14228b8f
commit
a99997b96d
4 files changed
+66
-7
No files matched your search
+40
-3
@@ -1,11 +1,43 @@
|
||||
import type { Wdl } from "./tables.ts";
|
||||
|
||||
type M = { session_id: number; p1_id: number; p2_id: number | null; p1_deck: string | null; p2_deck: string | null; p1_wins: number; p2_wins: number };
|
||||
type M = { session_id: number; p1_id: number; p2_id: number | null; p1_deck: string | null; p2_deck: string | null; p1_wins: number; p2_wins: number; draws?: number };
|
||||
type R = { session_id: number; player_id: number; points: number; deck: string | null; wins: number | null; draws: number | null; losses: number | null };
|
||||
|
||||
export type Tiebreak = { omw: number; gw: number; ogw: number };
|
||||
|
||||
/** Melee's (MTR) tiebreakers over every match row: opponents' match-win %, game-win %, opponents' game-win %.
|
||||
* A bye is a 2-0 win but not an opponent; opponents' percentages are floored at 1/3. Checked against Melee's
|
||||
* own values in a real standings export. */
|
||||
export function tiebreakers(ms: M[]): Map<number, Tiebreak> {
|
||||
const st = new Map<number, { mp: number; rounds: number; gp: number; games: number; opps: number[] }>();
|
||||
for (const m of ms) {
|
||||
const d = m.draws ?? 0;
|
||||
const sides: [number | null, number | null, number, number][] = [
|
||||
[m.p1_id, m.p2_id, m.p1_wins, m.p2_wins],
|
||||
[m.p2_id, m.p1_id, m.p2_wins, m.p1_wins],
|
||||
];
|
||||
for (const [pid, opp, me, them] of sides) {
|
||||
if (pid == null) continue;
|
||||
const s = st.get(pid) ?? { mp: 0, rounds: 0, gp: 0, games: 0, opps: [] };
|
||||
s.mp += me > them ? 3 : me === them ? 1 : 0;
|
||||
s.rounds++;
|
||||
s.gp += 3 * me + d;
|
||||
s.games += me + them + d;
|
||||
if (opp != null) s.opps.push(opp);
|
||||
st.set(pid, s);
|
||||
}
|
||||
}
|
||||
const mwp = (p: number) => { const s = st.get(p)!; return Math.max(1 / 3, s.mp / (3 * s.rounds)); };
|
||||
const gwp = (p: number) => { const s = st.get(p)!; return s.games ? s.gp / (3 * s.games) : 0; };
|
||||
const avg = (ps: number[], f: (p: number) => number) => (ps.length ? ps.reduce((a, p) => a + f(p), 0) / ps.length : 0);
|
||||
return new Map([...st].map(([p, s]) =>
|
||||
[p, { omw: avg(s.opps, mwp), gw: gwp(p), ogw: avg(s.opps, (o) => Math.max(1 / 3, gwp(o))) }]));
|
||||
}
|
||||
|
||||
/** 3 pts per match win, 1 per draw, 0 per loss (a bye is a win). A session's score is the sum of its match
|
||||
* points; the leaderboard adds up each player's best `bestN` sessions. Sessions with match rows use those;
|
||||
* otherwise the imported standings are used. */
|
||||
* otherwise the imported standings are used. Ties: total of all sessions, then Melee's tiebreakers over all the
|
||||
* league's matches, then name. */
|
||||
export function computeStandings(ms: M[], rs: R[], names: Map<number, string>, bestN: number) {
|
||||
const per = new Map<number, Map<number, number>>(); // player -> session -> points
|
||||
const rec = new Map<number, Wdl>();
|
||||
@@ -46,6 +78,7 @@ export function computeStandings(ms: M[], rs: R[], names: Map<number, string>, b
|
||||
});
|
||||
}
|
||||
|
||||
const tb = tiebreakers(ms);
|
||||
const rows = [...per].map(([pid, sessions]) => {
|
||||
const top = [...sessions.values()].sort((a, b) => b - a);
|
||||
return {
|
||||
@@ -54,8 +87,12 @@ export function computeStandings(ms: M[], rs: R[], names: Map<number, string>, b
|
||||
total: top.reduce((a, b) => a + b, 0),
|
||||
sessions: top.length,
|
||||
rec: partial.has(pid) ? null : rec.get(pid) ?? null,
|
||||
tb: tb.get(pid) ?? null, // null: no match rows (standings-only sessions)
|
||||
};
|
||||
}).sort((a, b) => b.score - a.score || b.total - a.total || a.name.localeCompare(b.name));
|
||||
});
|
||||
const desc = (x = 0, y = 0) => (Math.abs(x - y) < 1e-9 ? 0 : y - x); // float noise must not break a tie
|
||||
rows.sort((a, b) => b.score - a.score || b.total - a.total || desc(a.tb?.omw, b.tb?.omw) || desc(a.tb?.gw, b.tb?.gw) ||
|
||||
desc(a.tb?.ogw, b.tb?.ogw) || a.name.localeCompare(b.name));
|
||||
const sum = (w: Wdl) => w[0] + w[1] + w[2];
|
||||
return { rows, decks: [...decks].sort((a, b) => sum(b[1]) - sum(a[1])) };
|
||||
}
|
||||
Reference in new issue
Block a user