From b85f387c791cb157ef0422f581a53ec05d399513 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 18 Jul 2026 19:25:10 -0700 Subject: [PATCH] Rank the third place game winner instead of leaving them in contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Third Place Game winner kept showing under "In Contention" on the FIFA World Cup bracket even after the game was scored. The scoring side was already correct — TEMPLATE_ROUND_CONFIG.fifa_48 finalizes the 3PG winner at finalPosition 3 with isPartialScore false, so they got their 50 points. The bug was in PlayoffBracket, which re-derives rankings from match records rather than participant results and only ever ranks losers. The 3PG winner lost the semifinal but was correctly excluded there by the double-chance guard, then won the 3PG — so they were never a loser anywhere, never ranked, and fell through into the active list. Fixing that also fixes the rank labels, which double-counted positions 3-4: the 3PG loser was labelled T3 rather than 4, and quarterfinal losers T6 rather than T5. Rankings are now built by computeRankedEntries, which resolves a consolation round at its feeder round: the winner takes the top of the tier the feeder's losers would otherwise share and the loser the next position, with the consolation round itself consuming none. Positions are derived rather than hardcoded to 3/4, so a consolation round hanging off a different feeder lands correctly too. Only matches that can be placed exactly are withheld from the loser-driven walk, so an incomplete or partially hydrated consolation match falls back to the old path instead of being dropped. loserFeedsInto is set on exactly one round in the templates (fifa_48 Semifinals), so every other bracket keeps its existing behavior. Co-Authored-By: Claude Opus 4.8 --- app/components/scoring/PlayoffBracket.tsx | 180 ++++++-- .../scoring/__tests__/PlayoffBracket.test.tsx | 401 +++++++++++++++++- 2 files changed, 543 insertions(+), 38 deletions(-) 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(); + }); + }); +});