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:
derekandClaude Opus 5.5 committed 2026-10-28 17:13:24 +00:00
1 parent 0e14228b8f
commit a99997b96d
4 files changed
+66 -7

No files matched your search

+3 -1
View File
@@ -19,7 +19,9 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
## Rules and decisions ## Rules and decisions
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points. - Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
Leaderboard = sum of each player's best `league.best_n` session scores; tiebreak = total of all sessions, then name. Leaderboard = sum of each player's best `league.best_n` session scores; tiebreak = total of all sessions, then Melee's
tiebreakers (OMW%, GW%, OGW% over all the league's match rows; bye = 2-0 win, not an opponent; opponents floored at 1/3;
verified identical to Melee on a real night), then name. Standings-only sessions add nothing to the tiebreakers.
- A session with match rows is scored from them; otherwise from imported standings (`session_results`). - A session with match rows is scored from them; otherwise from imported standings (`session_results`).
Standings imports have W-D-L but no opponents, so the leaderboard shows "–" W-D-L for players with such a session. Standings imports have W-D-L but no opponents, so the leaderboard shows "–" W-D-L for players with such a session.
- Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username), - Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username),
+40 -3
View File
@@ -1,11 +1,43 @@
import type { Wdl } from "./tables.ts"; 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 }; 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 /** 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; * 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) { 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 per = new Map<number, Map<number, number>>(); // player -> session -> points
const rec = new Map<number, Wdl>(); 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 rows = [...per].map(([pid, sessions]) => {
const top = [...sessions.values()].sort((a, b) => b - a); const top = [...sessions.values()].sort((a, b) => b - a);
return { 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), total: top.reduce((a, b) => a + b, 0),
sessions: top.length, sessions: top.length,
rec: partial.has(pid) ? null : rec.get(pid) ?? null, 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]; const sum = (w: Wdl) => w[0] + w[1] + w[2];
return { rows, decks: [...decks].sort((a, b) => sum(b[1]) - sum(a[1])) }; return { rows, decks: [...decks].sort((a, b) => sum(b[1]) - sum(a[1])) };
} }
+18 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { computeStandings } from "../src/standings.ts"; import { computeStandings, tiebreakers } from "../src/standings.ts";
import { csvRows, extract, extractMatches, isMatchExport, parseTable } from "../src/tables.ts"; import { csvRows, extract, extractMatches, isMatchExport, parseTable } from "../src/tables.ts";
const fixture = (f: string) => parseTable(readFileSync(new URL(`./fixtures/${f}`, import.meta.url), "utf8")); const fixture = (f: string) => parseTable(readFileSync(new URL(`./fixtures/${f}`, import.meta.url), "utf8"));
@@ -66,3 +66,20 @@ test("a session with match rows ignores its imported standings", () => {
const rs = [{ session_id: 1, player_id: 2, points: 99, deck: null, wins: 9, draws: 0, losses: 0 }]; const rs = [{ session_id: 1, player_id: 2, points: 99, deck: null, wins: 9, draws: 0, losses: 0 }];
assert.equal(computeStandings(ms, rs, names, 6).rows.find((r) => r.name === "Bob")!.score, 0); assert.equal(computeStandings(ms, rs, names, 6).rows.find((r) => r.name === "Bob")!.score, 0);
}); });
const match = (p1_id: number, p2_id: number | null, p1_wins: number, p2_wins: number, draws = 0) =>
({ session_id: 1, p1_id, p2_id, p1_deck: null, p2_deck: null, p1_wins, p2_wins, draws });
test("Melee tiebreakers: a bye is a 2-0 win but not an opponent, opponents floored at 1/3", () => {
const tb = tiebreakers([match(1, null, 2, 0), match(1, 2, 2, 1)]);
assert.deepEqual(tb.get(1), { omw: 1 / 3, gw: 12 / 15, ogw: 1 / 3 }); // Fay went 0-1 and 1-2, both floored
assert.deepEqual(tb.get(2), { omw: 1, gw: 1 / 3, ogw: 12 / 15 });
});
test("leaderboard: points ties fall to OMW%, then GW%, before name", () => {
const names = new Map([[1, "Ann"], [2, "Bob"], [3, "Cy"], [4, "Dee"]]);
const ms = [match(1, 2, 2, 0), match(3, 4, 2, 0), match(1, 3, 2, 1), match(2, 4, 2, 0)];
const { rows } = computeStandings(ms, [], names, 6);
// Bob and Cy: 3 points, equal OMW (Ann 100%, Dee floored to 33%); Cy's GW% 60% beats Bob's 50%
assert.deepEqual(rows.map((r) => r.name), ["Ann", "Cy", "Bob", "Dee"]);
});
+5 -2
View File
@@ -1,9 +1,12 @@
<%- include('head') %> <%- include('head') %>
<h1><%= lg.name %></h1> <h1><%= lg.name %></h1>
<h2>Leaderboard <small>(best <%= lg.best_n %> sessions)</small></h2> <h2>Leaderboard <small>(best <%= lg.best_n %> sessions)</small></h2>
<table><tr><th>#</th><th>Player</th><th>Counted</th><th>All sessions</th><th>Played</th><th>W-D-L</th></tr> <table><tr><th>#</th><th>Player</th><th>Counted</th><th>All sessions</th><th>Played</th><th>W-D-L</th><th>OMW%</th><th>GW%</th><th>OGW%</th></tr>
<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %>
<% rows.forEach((r, i) => { %><tr><td><%= i + 1 %></td><td><%= r.name %></td><td><b><%= r.score %></b></td><td><%= r.total %></td> <% rows.forEach((r, i) => { %><tr><td><%= i + 1 %></td><td><%= r.name %></td><td><b><%= r.score %></b></td><td><%= r.total %></td>
<td><%= r.sessions %></td><td><%= r.rec ? r.rec.join('-') : '–' %></td></tr><% }) %></table> <td><%= r.sessions %></td><td><%= r.rec ? r.rec.join('-') : '–' %></td>
<td><%= pct(r, 'omw') %></td><td><%= pct(r, 'gw') %></td><td><%= pct(r, 'ogw') %></td></tr><% }) %></table>
<p><small>Ties: total of all sessions, then Melee's tiebreakers (opponents' match-win %, game-win %, opponents' game-win %) over all league matches.</small></p>
<h2>Sessions</h2> <h2>Sessions</h2>
<ul><% sessions.forEach(g => { %><li><a href="<%= base %>/session/<%= g.id %>">Session <%= g.number %> – <%= g.date %></a></li><% }) %></ul> <ul><% sessions.forEach(g => { %><li><a href="<%= base %>/session/<%= g.id %>">Session <%= g.number %> – <%= g.date %></a></li><% }) %></ul>
<% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %> <% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %>