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:
parent
131945ddeb
commit
c604dc9bc5
3 changed files with 50 additions and 10 deletions
|
|
@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue