diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 7f12868..a3def30 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon"; import { RankingsRow } from "./RankingsRow"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreePaginated } from "./BracketTreePaginated"; -import { getBracketTemplate } from "~/lib/bracket-templates"; +import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates"; import { NbaBracketLayout } from "./NbaBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout"; @@ -174,6 +174,138 @@ export function computeEliminatedByRound( return result; } +/** The score recorded for one participant in a match, or null if they didn't play in it. */ +function participantScore(match: Match, participantId: string | null): string | null { + if (!participantId) return null; + if (participantId === match.participant1Id) return match.participant1Score; + if (participantId === match.participant2Id) return match.participant2Score; + return null; +} + +/** + * A consolation final: a round contested by the losers of an earlier round, which + * splits the positions those losers would otherwise share. FIFA's "Third Place Game" + * (fed by the Semifinals) is the only one in the templates today. + */ +export interface ConsolationRound { + /** The consolation round itself, e.g. "Third Place Game". */ + round: string; + /** The round whose losers contest it, e.g. "Semifinals". */ + feederRound: string; +} + +/** + * Find the template's consolation round, if it has one. + * Exported for unit testing. + */ +export function findConsolationRound( + template: BracketTemplate | undefined +): ConsolationRound | undefined { + const feeder = template?.rounds.find((r) => r.loserFeedsInto); + if (!feeder?.loserFeedsInto) return undefined; + return { round: feeder.loserFeedsInto, feederRound: feeder.name }; +} + +/** + * Build the ordered final-rankings list from completed matches. + * + * Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the + * previous round's losers share the next tier, and so on — each round consuming as + * many positions as it has matches. + * + * A consolation round needs different handling, because its winner never loses a + * match and so the loser-driven walk above would leave them unranked and "in + * contention" forever. Its two places are exactly the top of the tier its feeder + * round's losers would otherwise share, so it is resolved *at the feeder round* — + * the winner takes that tier's first position and the loser the second — and the + * consolation round itself consumes no positions. Positions are derived rather than + * hardcoded, so a consolation round hanging off a different feeder still lands right. + * + * Exported for unit testing. + */ +export function computeRankedEntries( + matches: Match[], + rounds: string[], + matchesByRound: Map, + consolation: ConsolationRound | undefined, + ownershipMap: Map +): EliminatedEntry[] { + const eliminatedByRound = computeEliminatedByRound(matches, rounds); + + // Only take the consolation path when both rounds actually have matches; otherwise + // fall through to the loser-driven walk so nothing is dropped. + const consolationActive = + consolation && + rounds.includes(consolation.round) && + rounds.includes(consolation.feederRound); + + // Consolation matches we can place exactly. Anything else in that round (still in + // progress, or missing its hydrated winner/loser) deliberately stays eligible for + // the loser-driven walk rather than being silently dropped. + const consolationMatches = + consolationActive && consolation + ? (matchesByRound.get(consolation.round) ?? []).filter( + (m): m is Match & { winner: Participant; loser: Participant } => + m.isComplete && !!m.winner && !!m.loser + ) + : []; + const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id)); + + const entryFor = ( + match: Match, + participant: Participant, + participantId: string | null + ): Omit => ({ + participant, + score: participantScore(match, participantId), + ownership: ownershipMap.get(participant.id) || null, + }); + + const losersByRound = new Map[]>(); + for (const match of matches) { + if (!match.isComplete || !match.loser) continue; + if (exactlyPlacedMatchIds.has(match.id)) continue; + if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue; + if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); + losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId)); + } + + const rankedEntries: EliminatedEntry[] = []; + let nextRank = 2; + for (let ri = rounds.length - 1; ri >= 0; ri--) { + const roundName = rounds[ri]; + + // The consolation match splits the top of its feeder round's tier, so it is + // placed first and the round's remaining losers share what's left below it. + let tierRank = nextRank; + if (consolationActive && roundName === consolation?.feederRound) { + for (const match of consolationMatches) { + rankedEntries.push({ + ...entryFor(match, match.winner, match.winnerId), + rankLabel: `${tierRank}`, + }); + rankedEntries.push({ + ...entryFor(match, match.loser, match.loserId), + rankLabel: `${tierRank + 1}`, + }); + tierRank += 2; + } + } + + for (const loser of losersByRound.get(roundName) ?? []) { + rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` }); + } + + // The consolation round's places belong to its feeder round's tier, so it + // consumes none of its own. + if (consolationActive && roundName === consolation?.round) continue; + + nextRank += matchesByRound.get(roundName)?.length ?? 0; + } + + return rankedEntries; +} + /** Find the index of the first round that has scoring matches. */ function firstScoringRoundIdx(matchesByRound: Map, rounds: string[]): number { for (let i = 0; i < rounds.length; i++) { @@ -216,12 +348,10 @@ export function PlayoffBracket({ const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; - const thirdPlaceRound = template?.rounds - .find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name)) - ?.name; + const consolation = findConsolationRound(template); + const thirdPlaceRound = consolation?.round; // Build elimination rankings - const losersByRound = new Map>(); let bracketWinner: Participant | null = null; const lastRound = rounds[rounds.length - 1]; @@ -230,43 +360,19 @@ export function PlayoffBracket({ : null; if (finalMatch?.winner) bracketWinner = finalMatch.winner; - const eliminatedByRound = computeEliminatedByRound(matches, rounds); - - for (const match of matches) { - if (!match.isComplete || !match.loser) continue; - const eliminatedInRound = eliminatedByRound.get(match.round); - if (!eliminatedInRound?.includes(match.loser.id)) continue; - const loserScore = - match.loserId === match.participant1Id - ? match.participant1Score - : match.participant2Score; - if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); - losersByRound.get(match.round)?.push({ - participant: match.loser, - score: loserScore, - ownership: ownershipMap.get(match.loser.id) || null, - }); - } - const allBracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); } - const rankedEntries: EliminatedEntry[] = []; - let nextRank = 2; - for (let ri = rounds.length - 1; ri >= 0; ri--) { - const roundName = rounds[ri]; - const roundLosers = losersByRound.get(roundName) || []; - const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0; - if (roundLosers.length > 0) { - const rankLabel = `T${nextRank}`; - for (const loser of roundLosers) { - rankedEntries.push({ ...loser, rankLabel }); - } - } - nextRank += totalMatchesInRound; - } + + const rankedEntries = computeRankedEntries( + matches, + rounds, + matchesByRound, + consolation, + ownershipMap + ); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx index 3a76506..42c3a07 100644 --- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -1,5 +1,15 @@ import { describe, it, expect } from "vitest"; -import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket"; +import { render, screen, within } from "@testing-library/react"; +import { + PlayoffBracket, + buildFeederMap, + groupMatchesByRound, + computeEliminatedByRound, + computeRankedEntries, + findConsolationRound, + type Match, +} from "../PlayoffBracket"; +import { getBracketTemplate } from "~/lib/bracket-templates"; // --------------------------------------------------------------------------- // Helpers @@ -291,3 +301,392 @@ describe("computeEliminatedByRound", () => { }); }); }); + + +// --------------------------------------------------------------------------- +// computeRankedEntries — consolation ("third place") round handling +// --------------------------------------------------------------------------- + +// fifa_48 round order. The Third Place Game sits between the Semifinals (whose +// losers feed it) and the Finals. +const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"]; + +const FIFA_CONSOLATION = { + round: "Third Place Game", + feederRound: "Semifinals", +}; + +type MatchOpts = { + /** Which slot the winner occupies. Defaults to 1. */ + winnerSlot?: 1 | 2; + winnerScore?: string; + loserScore?: string; + /** + * Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a + * hand-built match object can arrive in. The loser is still placeable this way. + */ + missingWinnerRelation?: boolean; +}; + +/** A completed match. */ +function makeRankedMatch( + round: string, + matchNumber: number, + winnerId: string, + loserId: string, + opts: MatchOpts = {} +): Match { + const winnerIsP1 = (opts.winnerSlot ?? 1) === 1; + const p1 = winnerIsP1 ? winnerId : loserId; + const p2 = winnerIsP1 ? loserId : winnerId; + return { + id: `${round}-${matchNumber}`, + round, + matchNumber, + participant1Id: p1, + participant2Id: p2, + winnerId, + loserId, + isComplete: true, + participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null, + participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null, + participant1: { id: p1, name: p1 }, + participant2: { id: p2, name: p2 }, + winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId }, + loser: { id: loserId, name: loserId }, + }; +} + +/** + * A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots + * filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers. + */ +function makePendingMatch( + round: string, + matchNumber: number, + participant1Id: string | null, + participant2Id: string | null +): Match { + return { + id: `${round}-${matchNumber}`, + round, + matchNumber, + participant1Id, + participant2Id, + winnerId: null, + loserId: null, + isComplete: false, + participant1Score: null, + participant2Score: null, + participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null, + participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null, + winner: null, + loser: null, + }; +} + +/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */ +function fifaMatches( + overrides: { played?: boolean; thirdPlace?: Match } = {} +): Match[] { + const played = overrides.played ?? true; + return [ + makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"), + makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"), + makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"), + makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"), + makeRankedMatch("Semifinals", 1, "sfA", "sfB"), + makeRankedMatch("Semifinals", 2, "sfC", "sfD"), + overrides.thirdPlace ?? + (played + ? makeRankedMatch("Third Place Game", 1, "sfB", "sfD") + : makePendingMatch("Third Place Game", 1, "sfB", "sfD")), + played + ? makeRankedMatch("Finals", 1, "sfA", "sfC") + : makePendingMatch("Finals", 1, "sfA", "sfC"), + ]; +} + +/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */ +function rankFifa( + matches: Match[] = fifaMatches(), + ownership: Map = new Map(), + consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION +) { + return computeRankedEntries( + matches, + FIFA_ROUNDS, + groupMatchesByRound(matches), + consolation, + ownership + ); +} + +function rankOf(entries: ReturnType, id: string) { + return entries.find((e) => e.participant.id === id)?.rankLabel; +} + +describe("findConsolationRound", () => { + it("identifies the fifa_48 third place game and the round that feeds it", () => { + expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({ + round: "Third Place Game", + feederRound: "Semifinals", + }); + }); + + it("returns undefined for a template with no consolation round", () => { + expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined(); + }); + + it("returns undefined when there is no template", () => { + expect(findConsolationRound(undefined)).toBeUndefined(); + }); +}); + +describe("computeRankedEntries", () => { + describe("fifa_48 third place game", () => { + it("ranks the third place game winner 3rd — they never lose a match after the SF", () => { + // sfB lost the semifinal, then won the 3PG. + expect(rankOf(rankFifa(), "sfB")).toBe("3"); + }); + + it("ranks the third place game loser 4th, not 3rd", () => { + expect(rankOf(rankFifa(), "sfD")).toBe("4"); + }); + + it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => { + const entries = rankFifa(); + expect(rankOf(entries, "qf1")).toBe("T5"); + expect(rankOf(entries, "qf2")).toBe("T5"); + expect(rankOf(entries, "qf3")).toBe("T5"); + expect(rankOf(entries, "qf4")).toBe("T5"); + }); + + it("ranks the finals loser 2nd and leaves the champion out of the list", () => { + const entries = rankFifa(); + expect(rankOf(entries, "sfC")).toBe("T2"); + expect(rankOf(entries, "sfA")).toBeUndefined(); + }); + + it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => { + expect(rankFifa().map((e) => e.rankLabel)).toEqual([ + "T2", + "3", + "4", + "T5", + "T5", + "T5", + "T5", + ]); + }); + + it("leaves semifinal losers unranked until the third place game is played", () => { + const entries = rankFifa(fifaMatches({ played: false })); + // Both SF losers are still alive for the 3PG. + expect(rankOf(entries, "sfB")).toBeUndefined(); + expect(rankOf(entries, "sfD")).toBeUndefined(); + // QF losers are still T5 — the later rounds still consume their positions. + expect(rankOf(entries, "qf1")).toBe("T5"); + }); + + it("carries ownership through onto the third place entries", () => { + const ownership = new Map([ + ["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }], + ]); + const entries = rankFifa(fifaMatches(), ownership); + expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe( + "Team Nine" + ); + expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull(); + }); + }); + + describe("scores", () => { + it("reads each participant's own score regardless of which slot they occupied", () => { + const matches = fifaMatches({ + // Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores. + thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", { + winnerSlot: 2, + winnerScore: "3", + loserScore: "1", + }), + }); + const entries = rankFifa(matches); + + expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3"); + expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1"); + }); + + it("reads a loser's score from the slot they actually played in", () => { + const matches: Match[] = [ + makeRankedMatch("Semifinals", 1, "sfA", "sfB", { + winnerSlot: 2, + winnerScore: "4", + loserScore: "2", + }), + ]; + const entries = computeRankedEntries( + matches, + ["Semifinals"], + groupMatchesByRound(matches), + undefined, + new Map() + ); + + expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2"); + }); + + it("reports no score for a participant who occupies neither slot", () => { + // A stale row after a bracket edit: loserId no longer matches either slot. + // Attributing the other team's score here would look entirely plausible. + const stale: Match = { + ...makeRankedMatch("Semifinals", 1, "sfA", "sfB", { + winnerScore: "4", + loserScore: "2", + }), + loserId: "ghost", + loser: { id: "ghost", name: "ghost" }, + }; + const entries = computeRankedEntries( + [stale], + ["Semifinals"], + groupMatchesByRound([stale]), + undefined, + new Map() + ); + + expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull(); + }); + }); + + describe("a consolation round somewhere other than 3rd/4th", () => { + // No template ships this today, but the positions must come from the feeder + // round rather than being hardcoded to 3 and 4. + const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"]; + const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" }; + + const matches: Match[] = [ + makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"), + makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"), + makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"), + makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"), + makeRankedMatch("Semifinals", 1, "sfA", "sfB"), + makeRankedMatch("Semifinals", 2, "sfC", "sfD"), + makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"), + makeRankedMatch("Finals", 1, "sfA", "sfC"), + ]; + + it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => { + const entries = computeRankedEntries( + matches, + ROUNDS, + groupMatchesByRound(matches), + CONSOLATION, + new Map() + ); + + expect(rankOf(entries, "qf1")).toBe("5"); + expect(rankOf(entries, "qf2")).toBe("6"); + // The feeder round's other losers start below the pair, not alongside them. + expect(rankOf(entries, "qf3")).toBe("T7"); + expect(rankOf(entries, "qf4")).toBe("T7"); + // The rounds above it are unaffected. + expect(rankOf(entries, "sfC")).toBe("T2"); + expect(rankOf(entries, "sfB")).toBe("T3"); + }); + }); + + describe("consolation matches that cannot be placed exactly", () => { + it("still ranks the loser when the winner relation is missing", () => { + const matches = fifaMatches({ + thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", { + missingWinnerRelation: true, + }), + }); + const entries = rankFifa(matches); + + // The winner cannot be placed without a participant object, but the loser must + // not silently vanish the way it would if the round were skipped wholesale. + expect(rankOf(entries, "sfD")).toBeDefined(); + }); + + it("falls back to the loser-driven walk when the feeder round has no matches", () => { + const matches: Match[] = [ + makeRankedMatch("Third Place Game", 1, "sfB", "sfD"), + makeRankedMatch("Finals", 1, "sfA", "sfC"), + ]; + const entries = computeRankedEntries( + matches, + ["Third Place Game", "Finals"], + groupMatchesByRound(matches), + FIFA_CONSOLATION, + new Map() + ); + + expect(rankOf(entries, "sfC")).toBe("T2"); + expect(rankOf(entries, "sfD")).toBeDefined(); + }); + }); + + describe("rendered output", () => { + /** The card the 3PG winner was incorrectly appearing in. */ + function inContentionNames() { + const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]'); + if (!card) return []; + return within(card as HTMLElement) + .getAllByRole("row") + .map((r) => r.textContent ?? ""); + } + + it("does not list the third place game winner as in contention", () => { + render( + + ); + + // sfB won the third place game — they are finished, not still playing. + expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false); + }); + + it("still lists semifinalists as in contention before the third place game", () => { + render( + + ); + + expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true); + }); + }); + + describe("brackets without a consolation round", () => { + const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"]; + + it("ranks losers by round with tie labels, unchanged", () => { + const matches: Match[] = [ + makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"), + makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"), + makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"), + makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"), + makeRankedMatch("Semifinals", 1, "sfA", "sfB"), + makeRankedMatch("Semifinals", 2, "sfC", "sfD"), + makeRankedMatch("Finals", 1, "sfA", "sfC"), + ]; + + const entries = computeRankedEntries( + matches, + ROUNDS, + groupMatchesByRound(matches), + undefined, + new Map() + ); + + expect(rankOf(entries, "sfC")).toBe("T2"); + expect(rankOf(entries, "sfB")).toBe("T3"); + expect(rankOf(entries, "sfD")).toBe("T3"); + expect(rankOf(entries, "qf1")).toBe("T5"); + expect(rankOf(entries, "sfA")).toBeUndefined(); + }); + }); +});