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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-29 16:59:13 -07:00 committed by GitHub
parent 131945ddeb
commit c604dc9bc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 50 additions and 10 deletions

View file

@ -240,9 +240,10 @@ describe("calculateTeamProjectedScore", () => {
describe("calculateTeamScore", () => { describe("calculateTeamScore", () => {
// calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted // calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted
// to the teamStandings table. Partial-score participants (still alive in bracket) // 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 // QF survivor: finalPosition=5 (floor=20), isPartialScore=true
const db = makeDb([ const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), 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.totalPoints).toBe(AVG_5_TO_8); // 20 floor in score
expect(result.participantsCompleted).toBe(0); // still alive → not completed 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); expect(result.participantsTotal).toBe(1);
}); });
@ -283,9 +284,9 @@ describe("calculateTeamScore", () => {
expect(result.placementCounts[1]).toBe(0); // no placement badge 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++ // 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([ const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }), makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }), makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
@ -297,6 +298,6 @@ describe("calculateTeamScore", () => {
expect(result.participantsCompleted).toBe(1); expect(result.participantsCompleted).toBe(1);
expect(result.participantsTotal).toBe(2); expect(result.participantsTotal).toBe(2);
expect(result.placementCounts[1]).toBe(1); expect(result.placementCounts[1]).toBe(1);
expect(result.placementCounts[5]).toBe(0); expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
}); });
}); });

View file

@ -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<number, number> },
{ teamId: "ender", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
{ teamId: "zed", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
];
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<number, number> },
{ teamId: "team-b", totalPoints: 20, placementCounts: { 2: 1 } as Record<number, number> },
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
});
describe("Complex Tiebreaker Scenarios", () => { describe("Complex Tiebreaker Scenarios", () => {
it("should handle 8-way cascade ending in complete tie", () => { it("should handle 8-way cascade ending in complete tie", () => {
// Two teams identical through all 8 placements // Two teams identical through all 8 placements

View file

@ -900,12 +900,16 @@ export async function calculateTeamScore(
} }
totalPoints += points; 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) { if (!result.isPartialScore) {
// Only fully finalized participants count toward completed and placement tiebreakers // Only fully finalized participants count toward "completed" progress tracking
participantsCompleted++; participantsCompleted++;
if (result.finalPosition >= 1 && result.finalPosition <= 8) {
placementCounts[result.finalPosition]++;
}
} }
} else if (result && !result.isPartialScore) { } else if (result && !result.isPartialScore) {
// Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done // Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done