From c604dc9bc54ed87f12ca54b00a0a7330ba102008 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:59:13 -0700 Subject: [PATCH] Fix tiebreaker incorrectly separating tied teams with pending participants, fixes #244 (#245) Placement counts (used to break points ties) previously only included fully-finalized participants. A team whose events all resolved first would accumulate real placement counts while tied teams with pending events had zeros, causing the tie to be broken in favour of whichever team finished scoring first rather than on merit. Fix: count a participant's current position toward the tiebreaker regardless of isPartialScore. Their position is the best available signal; if it changes, recalculateStandings reruns and ranks update naturally. Also removes a redundant bounds check (finalPosition > 0 was already asserted by the outer if-condition). Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/team-projected-score.test.ts | 13 +++---- app/models/__tests__/tiebreaker-logic.test.ts | 35 +++++++++++++++++++ app/models/scoring-calculator.ts | 12 ++++--- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/models/__tests__/team-projected-score.test.ts b/app/models/__tests__/team-projected-score.test.ts index 5148b04..0354fee 100644 --- a/app/models/__tests__/team-projected-score.test.ts +++ b/app/models/__tests__/team-projected-score.test.ts @@ -240,9 +240,10 @@ describe("calculateTeamProjectedScore", () => { describe("calculateTeamScore", () => { // calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted // to the teamStandings table. Partial-score participants (still alive in bracket) - // must NOT be counted as completed or appear in placement tiebreakers. + // count toward placementCounts using their current position (best available signal), + // but must NOT be counted as completed — they're still in progress. - it("partial-score participant contributes floor to totalPoints but not to placementCounts or participantsCompleted", async () => { + it("partial-score participant contributes floor to totalPoints and placementCounts, but not participantsCompleted", async () => { // QF survivor: finalPosition=5 (floor=20), isPartialScore=true const db = makeDb([ makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), @@ -252,7 +253,7 @@ describe("calculateTeamScore", () => { expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 floor in score expect(result.participantsCompleted).toBe(0); // still alive → not completed - expect(result.placementCounts[5]).toBe(0); // no placement badge until finalized + expect(result.placementCounts[5]).toBe(1); // current position counts toward tiebreaker expect(result.participantsTotal).toBe(1); }); @@ -283,9 +284,9 @@ describe("calculateTeamScore", () => { expect(result.placementCounts[1]).toBe(0); // no placement badge }); - it("mixed team: partial counts in totalPoints only; finalized counts everywhere", async () => { + it("mixed team: partial counts in totalPoints and placementCounts; finalized also counts as completed", async () => { // p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++ - // p2: partial pos 5 (floor 20) → totalPoints +20, no placement, not completed + // p2: partial pos 5 (floor 20) → totalPoints +20, placementCounts[5]++, not completed const db = makeDb([ makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }), makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), @@ -297,6 +298,6 @@ describe("calculateTeamScore", () => { expect(result.participantsCompleted).toBe(1); expect(result.participantsTotal).toBe(2); expect(result.placementCounts[1]).toBe(1); - expect(result.placementCounts[5]).toBe(0); + expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker }); }); diff --git a/app/models/__tests__/tiebreaker-logic.test.ts b/app/models/__tests__/tiebreaker-logic.test.ts index a88e487..33a346f 100644 --- a/app/models/__tests__/tiebreaker-logic.test.ts +++ b/app/models/__tests__/tiebreaker-logic.test.ts @@ -292,6 +292,41 @@ describe("Tiebreaker Logic", () => { }); }); + describe("Pending Participants (issue #244)", () => { + // calculateTeamScore now includes partial-score participants' current positions + // in placementCounts. These tests verify the tiebreaker works correctly when + // pending positions are already reflected in the counts passed in. + + it("should rank teams tied on points equally when their current positions are also tied", () => { + // All six teams currently sit at 1st — their pending events haven't separated them yet. + // placementCounts reflect the current (partial) position, so all have { 1: 1 }. + const teams = [ + { teamId: "allen", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "ender", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "zed", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("allen")).toBe(1); + expect(ranks.get("ender")).toBe(1); + expect(ranks.get("zed")).toBe(1); + }); + + it("should break the tie using current position when one team is ahead in standings", () => { + // Two teams tied on points; one's pending participant is currently 1st, the other 2nd. + const teams = [ + { teamId: "team-a", totalPoints: 20, placementCounts: { 1: 1 } as Record }, + { teamId: "team-b", totalPoints: 20, placementCounts: { 2: 1 } as Record }, + ]; + + const ranks = assignRanks(teams); + + expect(ranks.get("team-a")).toBe(1); + expect(ranks.get("team-b")).toBe(2); + }); + }); + describe("Complex Tiebreaker Scenarios", () => { it("should handle 8-way cascade ending in complete tie", () => { // Two teams identical through all 8 placements diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index b48ddc4..450c616 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -900,12 +900,16 @@ export async function calculateTeamScore( } totalPoints += points; + // All participants with a valid position count toward the placement tiebreaker, + // including those still in progress (isPartialScore). Their current position + // is the best available signal, and if it changes recalculateStandings will run again. + if (result.finalPosition <= 8) { + placementCounts[result.finalPosition]++; + } + if (!result.isPartialScore) { - // Only fully finalized participants count toward completed and placement tiebreakers + // Only fully finalized participants count toward "completed" progress tracking participantsCompleted++; - if (result.finalPosition >= 1 && result.finalPosition <= 8) { - placementCounts[result.finalPosition]++; - } } } else if (result && !result.isPartialScore) { // Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done