From a99997b96d88fdb3f23018c73d56f5f55c2bd5c3 Mon Sep 17 00:00:00 2001 From: Derek Choi Date: Wed, 28 Oct 2026 17:13:24 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 4 +++- src/standings.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- test/league.test.ts | 19 ++++++++++++++++++- views/league.ejs | 7 +++++-- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5405be5..e7301ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,9 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio ## Rules and decisions - 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`). 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), diff --git a/src/standings.ts b/src/standings.ts index a6945ee..3513281 100644 --- a/src/standings.ts +++ b/src/standings.ts @@ -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 { + const st = new Map(); + 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, bestN: number) { const per = new Map>(); // player -> session -> points const rec = new Map(); @@ -46,6 +78,7 @@ export function computeStandings(ms: M[], rs: R[], names: Map, 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, 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])) }; } diff --git a/test/league.test.ts b/test/league.test.ts index cc8102b..45a8ef2 100644 --- a/test/league.test.ts +++ b/test/league.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; 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"; 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 }]; 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"]); +}); diff --git a/views/league.ejs b/views/league.ejs index 93e6005..fea0e03 100644 --- a/views/league.ejs +++ b/views/league.ejs @@ -1,9 +1,12 @@ <%- include('head') %>

<%= lg.name %>

Leaderboard (best <%= lg.best_n %> sessions)

- +
#PlayerCountedAll sessionsPlayedW-D-L
+<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %> <% rows.forEach((r, i) => { %> -<% }) %>
#PlayerCountedAll sessionsPlayedW-D-LOMW%GW%OGW%
<%= i + 1 %><%= r.name %><%= r.score %><%= r.total %><%= r.sessions %><%= r.rec ? r.rec.join('-') : '–' %>
+<%= r.sessions %><%= r.rec ? r.rec.join('-') : '–' %> +<%= pct(r, 'omw') %><%= pct(r, 'gw') %><%= pct(r, 'ogw') %><% }) %> +

Ties: total of all sessions, then Melee's tiebreakers (opponents' match-win %, game-win %, opponents' game-win %) over all league matches.

Sessions

<% if (admin) { %>
<% } %>