diff --git a/app/db/tables.ts b/app/db/tables.ts index 1fc41d731..99b392486 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -600,6 +600,8 @@ export interface TournamentResult { /** The SP change in total after the finalization of a ranked tournament. */ spDiff: number | null; userId: number; + /** Division label for tournaments with multiple starting brackets (e.g., "D1", "D2") */ + div: string | null; } export interface TournamentRoundMaps { diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts index a461068d9..ca3a39166 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts @@ -65,9 +65,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const season = Seasons.current(tournament.ctx.startTime)?.nth; const seedingSkillCountsFor = tournament.skillCountsFor; + const standingsResult = Standings.tournamentStandings(tournament); + const finalStandings = Standings.flattenStandings(standingsResult); const summary = tournamentSummary({ teams: tournament.ctx.teams, - finalStandings: Standings.tournamentStandings(tournament), + finalStandings, results, calculateSeasonalStats: tournament.ranked, queryCurrentTeamRating: (identifier) => @@ -85,6 +87,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { type: seedingSkillCountsFor!, }), seedingSkillCountsFor, + progression: tournament.ctx.settings.bracketProgression, }); const tournamentSummaryString = `Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`; diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index bb4dd1c6e..5cc13a04a 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -638,13 +638,13 @@ describe("isUnderground", () => { false, ); expect(Progression.isUnderground(1, progressions.manyStartBrackets)).toBe( - true, + false, ); expect(Progression.isUnderground(2, progressions.manyStartBrackets)).toBe( false, ); expect(Progression.isUnderground(3, progressions.manyStartBrackets)).toBe( - true, + false, ); }); @@ -720,6 +720,26 @@ describe("bracketIdxsForStandings", () => { }); }); +describe("startingBrackets", () => { + it("handles SE", () => { + expect( + Progression.startingBrackets(progressions.singleElimination), + ).toEqual([0]); + }); + + it("handles many starter brackets", () => { + expect( + Progression.startingBrackets(progressions.manyStartBrackets), + ).toEqual([0, 1]); + }); + + it("handles swiss (one group)", () => { + expect(Progression.startingBrackets(progressions.swissOneGroup)).toEqual([ + 0, + ]); + }); +}); + describe("destinationsFromBracketIdx", () => { it("returns correct destination (one destination)", () => { expect( diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 8ba8dd3b8..2439352cd 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -595,7 +595,17 @@ export function isFinals(idx: number, brackets: ParsedBracket[]) { export function isUnderground(idx: number, brackets: ParsedBracket[]) { invariant(idx < brackets.length, "Bracket index out of bounds"); - return !resolveMainBracketProgression(brackets).includes(idx); + const startBrackets = startingBrackets(brackets); + + for (const startBracketIdx of startBrackets) { + if ( + resolveMainBracketProgression(brackets, startBracketIdx).includes(idx) + ) { + return false; + } + } + + return true; } /** @@ -619,11 +629,14 @@ export function bracketDepth(idx: number, brackets: ParsedBracket[]): number { return Math.max(...sourceDepths) + 1; } -function resolveMainBracketProgression(brackets: ParsedBracket[]) { +function resolveMainBracketProgression( + brackets: ParsedBracket[], + startBracketIdx = 0, +) { if (brackets.length === 1) return [0]; - let bracketIdxToFind = 0; - const result = [0]; + let bracketIdxToFind = startBracketIdx; + const result = [startBracketIdx]; while (true) { const bracket = brackets.findIndex((bracket) => bracket.sources?.some( @@ -685,8 +698,11 @@ export function changedBracketProgressionFormat( return false; } -/** Returns the order of brackets as is to be considered for standings. Teams from the bracket of lower index are considered to be above those from the lower bracket. - * A participant's standing is the first bracket to appear in order that has the participant in it. +/** + * Returns the order of brackets as is to be considered for standings. Teams from the bracket of lower index are considered to be above those from the lower bracket. + * A participant's standing is the first bracket to appear in order that has the participant in it. + * + * The order is so that most significant brackets (i.e. finals) appear first. */ export function bracketIdxsForStandings(progression: ParsedBracket[]) { const bracketsToConsider = bracketsReachableFrom(0, progression); @@ -734,7 +750,7 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) { }); } -function bracketsReachableFrom( +export function bracketsReachableFrom( bracketIdx: number, progression: ParsedBracket[], ): number[] { @@ -794,3 +810,10 @@ export function destinationByPlacement({ return destination ?? null; } + +export function startingBrackets(progression: ParsedBracket[]): number[] { + return progression + .map((bracket, idx) => ({ bracket, idx })) + .filter(({ bracket }) => !bracket.sources) + .map(({ idx }) => idx); +} diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index b81ec76f2..18f4e3a34 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -7,12 +7,14 @@ import { rate, userIdsToIdentifier, } from "~/features/mmr/mmr-utils"; +import { getBracketProgressionLabel } from "~/features/tournament/tournament-utils"; import invariant from "~/utils/invariant"; import { roundToNDecimalPlaces } from "~/utils/number"; import type { Tables, WinLossParticipationArray } from "../../../db/tables"; import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; import { ensureOneStandingPerUser } from "../tournament-bracket-utils"; import type { Standing } from "./Bracket"; +import type { ParsedBracket } from "./Progression"; export interface TournamentSummary { skills: Omit< @@ -35,6 +37,7 @@ export interface TournamentSummary { type TeamsArg = Array<{ id: number; members: Array<{ userId: number }>; + startingBracketIdx?: number | null; }>; type Rating = Pick; @@ -53,6 +56,7 @@ export function tournamentSummary({ queryCurrentSeedingRating, seedingSkillCountsFor, calculateSeasonalStats = true, + progression, }: { results: AllMatchResult[]; teams: TeamsArg; @@ -63,6 +67,7 @@ export function tournamentSummary({ queryCurrentSeedingRating: (userId: number) => Rating; seedingSkillCountsFor: Tables["SeedingSkill"]["type"] | null; calculateSeasonalStats?: boolean; + progression: ParsedBracket[]; }): TournamentSummary { const skills = calculateSeasonalStats ? calculateSkills({ @@ -95,6 +100,8 @@ export function tournamentSummary({ tournamentResults: tournamentResults({ participantCount: teams.length, finalStandings: ensureOneStandingPerUser(finalStandings), + teams, + progression, }), spDiffs: calculateSeasonalStats ? spDiffs({ skills, queryCurrentUserRating }) @@ -495,19 +502,37 @@ function playerResultDeltas( function tournamentResults({ participantCount, finalStandings, + teams, + progression, }: { participantCount: number; finalStandings: Standing[]; + teams: TeamsArg; + progression: ParsedBracket[]; }) { const result: TournamentSummary["tournamentResults"] = []; + const firstPlaceFinishesCount = finalStandings.filter( + (s) => s.placement === 1, + ).length; + const isMultiStartingBracket = firstPlaceFinishesCount > 1; + for (const standing of finalStandings) { + const team = teams.find((t) => t.id === standing.team.id); + invariant(team); + const div = + // second check should be redundant, but just here in case + typeof team.startingBracketIdx === "number" && isMultiStartingBracket + ? getBracketProgressionLabel(team.startingBracketIdx, progression) + : null; + for (const player of standing.team.members) { result.push({ participantCount, placement: standing.placement, tournamentTeamId: standing.team.id, userId: player.userId, + div, }); } } diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 809905769..0fa9283e0 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -44,13 +44,78 @@ describe("tournamentSummary()", () => { results, seedingSkillCountsFor, withMemberInTwoTeams = false, + teamsWithStartingBrackets, + progression, + finalStandings, }: { results?: AllMatchResult[]; seedingSkillCountsFor?: Tables["SeedingSkill"]["type"]; withMemberInTwoTeams?: boolean; + teamsWithStartingBrackets?: Array<{ + id: number; + startingBracketIdx: number | null; + }>; + progression?: Array<{ + name: string; + type: "single_elimination"; + settings: Record; + requiresCheckIn: boolean; + sources?: Array<{ bracketIdx: number; placements: number[] }>; + }>; + finalStandings?: Array<{ + placement: number; + team: TournamentDataTeam; + }>; } = {}) { + const defaultTeams = [ + { + id: 1, + members: [ + { userId: 1 }, + { userId: 2 }, + { userId: 3 }, + { userId: 4 }, + { userId: 20 }, + ], + }, + { + id: 2, + members: [{ userId: 5 }, { userId: 6 }, { userId: 7 }, { userId: 8 }], + }, + { + id: 3, + members: [ + { userId: 9 }, + { userId: 10 }, + { userId: 11 }, + { userId: 12 }, + ], + }, + { + id: 4, + members: [ + { userId: 13 }, + { userId: 14 }, + { userId: 15 }, + { userId: 16 }, + ], + }, + ]; + + const teams = teamsWithStartingBrackets + ? defaultTeams.map((team) => { + const startingBracket = teamsWithStartingBrackets.find( + (t) => t.id === team.id, + ); + return { + ...team, + startingBracketIdx: startingBracket?.startingBracketIdx ?? null, + }; + }) + : defaultTeams; + return tournamentSummary({ - finalStandings: [ + finalStandings: finalStandings ?? [ { placement: 1, team: createTeam( @@ -117,45 +182,20 @@ describe("tournamentSummary()", () => { }, }, ], - teams: [ - { - id: 1, - members: [ - { userId: 1 }, - { userId: 2 }, - { userId: 3 }, - { userId: 4 }, - { userId: 20 }, - ], - }, - { - id: 2, - members: [{ userId: 5 }, { userId: 6 }, { userId: 7 }, { userId: 8 }], - }, - { - id: 3, - members: [ - { userId: 9 }, - { userId: 10 }, - { userId: 11 }, - { userId: 12 }, - ], - }, - { - id: 4, - members: [ - { userId: 13 }, - { userId: 14 }, - { userId: 15 }, - { userId: 16 }, - ], - }, - ], + teams, queryCurrentTeamRating: () => rating(), queryCurrentUserRating: () => ({ rating: rating(), matchesCount: 0 }), queryTeamPlayerRatingAverage: () => rating(), queryCurrentSeedingRating: () => rating(), seedingSkillCountsFor: seedingSkillCountsFor ?? null, + progression: progression ?? [ + { + name: "Main Bracket", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + }, + ], }); } @@ -620,4 +660,65 @@ describe("tournamentSummary()", () => { expect(results).toEqual(["W"]); } }); + + test("div is null when teams have no startingBracketIdx", () => { + const summary = summarize(); + + for (const result of summary.tournamentResults) { + expect(result.div).toBeNull(); + } + }); + + test("div is set correctly for teams with startingBracketIdx", () => { + const summary = summarize({ + teamsWithStartingBrackets: [ + { id: 1, startingBracketIdx: 0 }, + { id: 2, startingBracketIdx: 1 }, + { id: 3, startingBracketIdx: 0 }, + { id: 4, startingBracketIdx: 1 }, + ], + progression: [ + { + name: "Division 1", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Division 2", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + }, + ], + finalStandings: [ + { + placement: 1, + team: createTeam(1, [1, 2, 3, 4]), + }, + { + placement: 1, + team: createTeam(2, [5, 6, 7, 8]), + }, + { + placement: 2, + team: createTeam(3, [9, 10, 11, 12]), + }, + { + placement: 2, + team: createTeam(4, [13, 14, 15, 16]), + }, + ], + }); + + const team1Results = summary.tournamentResults.filter( + (r) => r.tournamentTeamId === 1, + ); + const team2Results = summary.tournamentResults.filter( + (r) => r.tournamentTeamId === 2, + ); + + expect(team1Results.every((r) => r.div === "Division 1")).toBeTruthy(); + expect(team2Results.every((r) => r.div === "Division 2")).toBeTruthy(); + }); }); diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts index 71d6703b0..855c4db9e 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts @@ -54,7 +54,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; async function standingsWithSetParticipation(tournament: Tournament) { - const finalStandings = Standings.tournamentStandings(tournament); + const standingsResult = Standings.tournamentStandings(tournament); + const finalStandings = Standings.flattenStandings(standingsResult); const results = allMatchResultsByTournamentId(tournament.ctx.id); invariant(results.length > 0, "No results found"); @@ -83,6 +84,7 @@ async function standingsWithSetParticipation(tournament: Tournament) { type: seedingSkillCountsFor!, }), seedingSkillCountsFor, + progression: tournament.ctx.settings.bracketProgression, }); return finalStandings.map((standing) => { diff --git a/app/features/tournament-bracket/queries/addSummary.server.ts b/app/features/tournament-bracket/queries/addSummary.server.ts index e3d3cfaae..36a105c3e 100644 --- a/app/features/tournament-bracket/queries/addSummary.server.ts +++ b/app/features/tournament-bracket/queries/addSummary.server.ts @@ -116,7 +116,8 @@ const addTournamentResultStm = sql.prepare(/* sql */ ` "participantCount", "tournamentTeamId", "setResults", - "spDiff" + "spDiff", + "div" ) values ( @tournamentId, @userId, @@ -124,7 +125,8 @@ const addTournamentResultStm = sql.prepare(/* sql */ ` @participantCount, @tournamentTeamId, @setResults, - @spDiff + @spDiff, + @div ) `); @@ -240,6 +242,7 @@ export const addSummary = sql.transaction( tournamentTeamId: tournamentResult.tournamentTeamId, setResults: setResults ? JSON.stringify(setResults) : null, spDiff: summary.spDiffs?.get(tournamentResult.userId) ?? null, + div: tournamentResult.div, }); } diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index c95e14501..11c3df3ba 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -2,6 +2,27 @@ import * as R from "remeda"; import type { Standing } from "~/features/tournament-bracket/core/Bracket"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import invariant from "~/utils/invariant"; +import { getBracketProgressionLabel } from "../tournament-utils"; + +export type TournamentStandingsResult = + | { type: "single"; standings: Standing[] } + | { + type: "multi"; + standings: Array<{ + div: string; + standings: Standing[]; + }>; + }; + +/** Converts tournament standings from single or multi-division format into a flat array */ +export function flattenStandings( + standingsResult: TournamentStandingsResult, +): Standing[] { + return standingsResult.type === "single" + ? standingsResult.standings + : standingsResult.standings.flatMap((div) => div.standings); +} /** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */ export function calculateSPR({ @@ -92,12 +113,67 @@ export function matchesPlayed({ * For example if the tournament format is round robin (where 2 out of 4 teams per group advance) to single elimination, * the top teams are decided by the single elimination bracket, and the teams who failed to make the bracket are ordered * by their performance in the round robin group stage. + * + * Returns a discriminated union: + * - For tournaments with a single starting bracket, returns type 'single' with overall standings + * - For tournaments with multiple starting brackets, returns type 'multi' with standings per division */ -export function tournamentStandings(tournament: Tournament): Standing[] { - const bracketIdxs = Progression.bracketIdxsForStandings( +export function tournamentStandings( + tournament: Tournament, +): TournamentStandingsResult { + const startingBracketIdxs = Progression.startingBrackets( tournament.ctx.settings.bracketProgression, ); + if (startingBracketIdxs.length <= 1) { + return { + type: "single", + standings: tournamentStandingsForBracket(tournament, undefined), + }; + } + + return { + type: "multi", + standings: startingBracketIdxs.map((bracketIdx) => ({ + div: getBracketProgressionLabel( + bracketIdx, + tournament.ctx.settings.bracketProgression, + ), + standings: tournamentStandingsForBracket(tournament, bracketIdx), + })), + }; +} + +/** + * Computes the standings for a given tournament starting from a specific bracket. + * If bracketIdx is undefined, computes overall standings for the entire tournament. + * Otherwise, only includes brackets that are reachable from the given bracketIdx. + */ +function tournamentStandingsForBracket( + tournament: Tournament, + bracketIdx: number | undefined, +): Standing[] { + let bracketIdxs: number[]; + + const isSingleStartingBracket = typeof bracketIdx !== "number"; + + if (isSingleStartingBracket) { + bracketIdxs = Progression.bracketIdxsForStandings( + tournament.ctx.settings.bracketProgression, + ); + } else { + const reachableBrackets = Progression.bracketsReachableFrom( + bracketIdx, + tournament.ctx.settings.bracketProgression, + ); + const reachableSet = new Set(reachableBrackets); + + const allBracketIdxs = tournament.ctx.settings.bracketProgression + .map((_, idx) => idx) + .sort((a, b) => b - a); + bracketIdxs = allBracketIdxs.filter((idx) => reachableSet.has(idx)); + } + const result: Standing[] = []; const alreadyIncludedTeamIds = new Set(); @@ -105,11 +181,14 @@ export function tournamentStandings(tournament: Tournament): Standing[] { (bracket) => bracket.isFinals && bracket.everyMatchOver, ); - for (const bracketIdx of bracketIdxs) { - const bracket = tournament.bracketByIdx(bracketIdx); - if (!bracket) continue; + for (const idx of bracketIdxs) { + const bracket = tournament.bracketByIdx(idx); + invariant(bracket); + // sometimes a bracket might not be played so then we ignore it from the standings - if (finalBracketIsOver && bracket.preview) continue; + if (isSingleStartingBracket && finalBracketIsOver && bracket.preview) { + continue; + } const standings = standingsToMergeable({ alreadyIncludedTeamIds, diff --git a/app/features/tournament/routes/to.$id.results.tsx b/app/features/tournament/routes/to.$id.results.tsx index 11267e449..01edba76c 100644 --- a/app/features/tournament/routes/to.$id.results.tsx +++ b/app/features/tournament/routes/to.$id.results.tsx @@ -2,10 +2,17 @@ import { Link } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; import { Avatar } from "~/components/Avatar"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; import { Flag } from "~/components/Flag"; import { InfoPopover } from "~/components/InfoPopover"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; +import type { Standing } from "~/features/tournament-bracket/core/Bracket"; import { SPR_INFO_URL, tournamentMatchPage, @@ -17,120 +24,152 @@ import { useTournament } from "./to.$id"; export default function TournamentResultsPage() { const tournament = useTournament(); - const standings = Standings.tournamentStandings(tournament); + const standingsResult = Standings.tournamentStandings(tournament); + + if (standingsResult.type === "single") { + if (standingsResult.standings.length === 0) { + return ( +
+ No team finished yet, check back later +
+ ); + } - if (standings.length === 0) { return ( -
- No team finished yet, check back later +
+
); } + return ( + + + {standingsResult.standings.map(({ div }) => ( + + {div} + + ))} + + {standingsResult.standings.map(({ div, standings }) => ( + + {standings.length === 0 ? ( +
+ No team finished yet, check back later +
+ ) : ( + + )} +
+ ))} +
+ ); +} + +function ResultsTable({ standings }: { standings: Standing[] }) { + const tournament = useTournament(); + let lastRenderedPlacement = 0; let rowDarkerBg = false; + return ( -
- - - - - - - - {tournament.ctx.isFinalized ? ( - + + + + + + {tournament.ctx.isFinalized ? ( + + ) : null} + + + + + {standings.map((standing, i) => { + const placement = + lastRenderedPlacement === standing.placement + ? null + : standing.placement; + lastRenderedPlacement = standing.placement; + + if (standing.placement !== standings[i - 1]?.placement) { + rowDarkerBg = !rowDarkerBg; + } + + const teamLogoSrc = tournament.tournamentTeamLogoSrc(standing.team); + + const spr = Standings.calculateSPR({ + standings, + teamId: standing.team.id, + }); + + return ( + + + + - - - - {standings.map((standing, i) => { - const placement = - lastRenderedPlacement === standing.placement - ? null - : standing.placement; - lastRenderedPlacement = standing.placement; - - if (standing.placement !== standings[i - 1]?.placement) { - rowDarkerBg = !rowDarkerBg; - } - - const teamLogoSrc = tournament.tournamentTeamLogoSrc(standing.team); - - const spr = Standings.calculateSPR({ - standings, - teamId: standing.team.id, - }); - - return ( - - + + {tournament.ctx.isFinalized ? ( + - - - - {tournament.ctx.isFinalized ? ( - - ) : null} - - - ); - })} - -
StandingTeamRosterSeed - SPR{" "} - - +
StandingTeamRosterSeed + SPR{" "} + + + Seed Performance Rating + + + Matches
+ {typeof placement === "number" ? ( + + ) : null}{" "} + + + {teamLogoSrc ? : null}{" "} + {standing.team.name} + + + {standing.team.members.map((player) => ( +
- Seed Performance Rating - - - - ) : null} -
Matches
- {typeof placement === "number" ? ( - - ) : null}{" "} + {player.country ? ( + + ) : null} + {player.username} + + ))} + {standing.team.seed} + {spr > 0 ? "+" : ""} + {spr} - - {teamLogoSrc ? ( - - ) : null}{" "} - {standing.team.name} - - - {standing.team.members.map((player) => ( -
- {player.country ? ( - - ) : null} - {player.username} -
- ))} -
{standing.team.seed} - {spr > 0 ? "+" : ""} - {spr} - - -
-
+ ) : null} + + + + + ); + })} + + ); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 8c88ad557..23ee0b7c8 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -107,7 +107,9 @@ function StatSquares({ const data = useLoaderData(); const tournament = useTournament(); - const placement = Standings.tournamentStandings(tournament).find( + const standingsResult = Standings.tournamentStandings(tournament); + const overallStandings = Standings.flattenStandings(standingsResult); + const placement = overallStandings.find( (s) => s.team.id === data.tournamentTeamId, )?.placement; @@ -170,6 +172,15 @@ function StatSquares({ {t("tournament:team.placement.footer")}
) : null} + {standingsResult.type === "multi" ? ( +
+ { + standingsResult.standings.find((s) => + s.standings.some((s) => s.team.id === data.tournamentTeamId), + )?.div + } +
+ ) : null} ); diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts new file mode 100644 index 000000000..f933ba7e6 --- /dev/null +++ b/app/features/tournament/tournament-utils.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedBracket } from "../tournament-bracket/core/Progression"; +import { getBracketProgressionLabel } from "./tournament-utils"; + +const createBracket = (name: string): ParsedBracket => ({ + name, + type: "single_elimination", + settings: {}, + requiresCheckIn: false, +}); + +describe("getBracketProgressionLabel", () => { + it("returns single bracket name when only one bracket is reachable", () => { + const progression: ParsedBracket[] = [createBracket("Main Bracket")]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Main Bracket"); + }); + + it("returns common prefix when multiple brackets share a prefix", () => { + const progression: ParsedBracket[] = [ + createBracket("Alpha"), + createBracket("Alpha A"), + createBracket("Alpha B"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [2] }]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Alpha"); + }); + + it("trims whitespace from common prefix", () => { + const progression: ParsedBracket[] = [ + createBracket("Playoff "), + createBracket("Playoff Winner"), + createBracket("Playoff Loser"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [2] }]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Playoff"); + }); + + it("returns deepest bracket name when no common prefix exists", () => { + const progression: ParsedBracket[] = [ + createBracket("Round Robin"), + createBracket("Winner Bracket"), + createBracket("Loser Bracket"), + createBracket("Grand Finals"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [2] }]; + progression[3].sources = [ + { bracketIdx: 1, placements: [1] }, + { bracketIdx: 2, placements: [1] }, + ]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Grand Finals"); + }); + + it("handles single character prefix", () => { + const progression: ParsedBracket[] = [ + createBracket("A"), + createBracket("A1"), + createBracket("A2"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [2] }]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("A"); + }); + + it("handles bracket progression with multiple levels", () => { + const progression: ParsedBracket[] = [ + createBracket("Qualifier"), + createBracket("Group A"), + createBracket("Group B"), + createBracket("Finals"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1, 2] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [3, 4] }]; + progression[3].sources = [ + { bracketIdx: 1, placements: [1] }, + { bracketIdx: 2, placements: [1] }, + ]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Finals"); + }); + + it("returns bracket name for progression with partial common prefix", () => { + const progression: ParsedBracket[] = [ + createBracket("Swiss"), + createBracket("Swiss Upper"), + createBracket("Swiss Lower"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1, 2] }]; + progression[2].sources = [{ bracketIdx: 0, placements: [3, 4] }]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("Swiss"); + }); + + it("handles empty string prefix by returning deepest bracket", () => { + const progression: ParsedBracket[] = [ + createBracket("A"), + createBracket("B"), + createBracket("C"), + ]; + + progression[1].sources = [{ bracketIdx: 0, placements: [1] }]; + progression[2].sources = [{ bracketIdx: 1, placements: [1] }]; + + const result = getBracketProgressionLabel(0, progression); + + expect(result).toBe("C"); + }); +}); diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 17ca7eb56..1345e5dc9 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -9,6 +9,8 @@ import { assertUnreachable } from "../../utils/types"; import { MapPool } from "../map-list-generator/core/map-pool"; import * as Seasons from "../mmr/core/Seasons"; import { BANNED_MAPS } from "../sendouq-settings/banned-maps"; +import type { ParsedBracket } from "../tournament-bracket/core/Progression"; +import * as Progression from "../tournament-bracket/core/Progression"; import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament"; import type { TournamentData } from "../tournament-bracket/core/Tournament.server"; import type { PlayedSet } from "./core/sets.server"; @@ -394,3 +396,45 @@ export function normalizedTeamCount({ }) { return teamsCount * minMembersPerTeam; } + +export function getBracketProgressionLabel( + startingBracketIdx: number, + progression: ParsedBracket[], +): string { + const reachableBracketIdxs = Progression.bracketsReachableFrom( + startingBracketIdx, + progression, + ); + + const uniqueBracketIdxs = Array.from(new Set(reachableBracketIdxs)); + const bracketNames = uniqueBracketIdxs.map((idx) => progression[idx].name); + + if (bracketNames.length === 1) { + return bracketNames[0]; + } + + let prefix = bracketNames[0]; + for (let i = 1; i < bracketNames.length; i++) { + const name = bracketNames[i]; + let j = 0; + while (j < prefix.length && j < name.length && prefix[j] === name[j]) { + j++; + } + prefix = prefix.substring(0, j); + if (prefix === "") break; + } + + prefix = prefix.trim(); + + if (!prefix) { + const deepestBracketIdx = uniqueBracketIdxs.reduce((deepest, current) => { + const currentDepth = Progression.bracketDepth(current, progression); + const deepestDepth = Progression.bracketDepth(deepest, progression); + return currentDepth > deepestDepth ? current : deepest; + }, uniqueBracketIdxs[0]); + + return progression[deepestBracketIdx].name; + } + + return prefix; +} diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 50edbc91d..8ec82715d 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -483,6 +483,7 @@ export function findResultsByUserId( "CalendarEventResultTeam.placement", "CalendarEvent.participantCount", sql`null`.as("setResults"), + sql`null`.as("div"), sql`null`.as("logoUrl"), "CalendarEvent.name as eventName", "CalendarEventResultTeam.id as teamId", @@ -520,6 +521,7 @@ export function findResultsByUserId( "TournamentResult.placement", "TournamentResult.participantCount", "TournamentResult.setResults", + "TournamentResult.div", eb .selectFrom("UserSubmittedImage") .select(["UserSubmittedImage.url"]) diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 38aa85c1e..ac05b7522 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -124,6 +124,9 @@ export function UserResultsTable({ > {result.eventName} + {result.div ? ( + ({result.div}) + ) : null} ) : null} diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 2c8521861..9544c6da9 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -34,7 +34,9 @@ export default function UserResultsPage() {

- {showAll ? t("results.title") : t("results.highlights")} + {showAll || !data.hasHighlightedResults + ? t("results.title") + : t("results.highlights")}

{user?.id === layoutData.user.id ? ( { + db.prepare(/* sql */ `alter table "TournamentResult" add "div" text`).run(); + })(); +} diff --git a/scripts/backfill-tournament-result-divisions.ts b/scripts/backfill-tournament-result-divisions.ts new file mode 100644 index 000000000..902914613 --- /dev/null +++ b/scripts/backfill-tournament-result-divisions.ts @@ -0,0 +1,135 @@ +import "dotenv/config"; +import { db } from "../app/db/sql"; +import * as Seasons from "../app/features/mmr/core/Seasons"; +import { + queryCurrentTeamRating, + queryCurrentUserRating, + queryCurrentUserSeedingRating, + queryTeamPlayerRatingAverage, +} from "../app/features/mmr/mmr-utils.server"; +import * as Standings from "../app/features/tournament/core/Standings"; +import { tournamentSummary } from "../app/features/tournament-bracket/core/summarizer.server"; +import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; +import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; +import invariant from "../app/utils/invariant"; +import { logger } from "../app/utils/logger"; + +async function main() { + logger.info("Starting to backfill tournament result divisions"); + + const tournaments = await db + .selectFrom("Tournament") + .select("id") + .where("isFinalized", "=", 1) + .execute(); + + let recalculatedCount = 0; + let skippedCount = 0; + + for (const { id: tournamentId } of tournaments) { + try { + const tournament = await tournamentFromDB({ + tournamentId, + user: undefined, + }); + + const uniqueStartingBracketIndexes = new Set( + tournament.ctx.teams + .map((team) => team.startingBracketIdx) + .filter((idx) => idx !== null && idx !== undefined), + ); + + if (uniqueStartingBracketIndexes.size <= 1) { + skippedCount++; + continue; + } + + recalculatedCount++; + + await db + .deleteFrom("TournamentResult") + .where("tournamentId", "=", tournamentId) + .execute(); + + const results = allMatchResultsByTournamentId(tournamentId); + invariant(results.length > 0, "No results found"); + + const season = Seasons.current(tournament.ctx.startTime)?.nth; + const seedingSkillCountsFor = tournament.skillCountsFor; + + const standingsResult = Standings.tournamentStandings(tournament); + if (standingsResult.type === "single") { + throw new Error( + `Expected multiple starting brackets for tournament ${tournamentId}`, + ); + } + const finalStandings = Standings.flattenStandings(standingsResult); + const summary = tournamentSummary({ + teams: tournament.ctx.teams, + finalStandings, + results, + calculateSeasonalStats: false, + queryCurrentTeamRating: (identifier) => + queryCurrentTeamRating({ identifier, season: season! }).rating, + queryCurrentUserRating: (userId) => + queryCurrentUserRating({ userId, season: season! }), + queryTeamPlayerRatingAverage: (identifier) => + queryTeamPlayerRatingAverage({ + identifier, + season: season!, + }), + queryCurrentSeedingRating: (userId) => + queryCurrentUserSeedingRating({ + userId, + type: seedingSkillCountsFor!, + }), + seedingSkillCountsFor, + progression: tournament.ctx.settings.bracketProgression, + }); + + logger.info( + `Inserting ${summary.tournamentResults.length} results for tournament ${tournamentId}`, + ); + for (const tournamentResult of summary.tournamentResults) { + const setResults = summary.setResults.get(tournamentResult.userId); + + if (setResults?.every((result) => !result)) { + continue; + } + + await db + .insertInto("TournamentResult") + .values({ + tournamentId, + userId: tournamentResult.userId, + placement: tournamentResult.placement, + participantCount: tournamentResult.participantCount, + tournamentTeamId: tournamentResult.tournamentTeamId, + setResults: setResults ? JSON.stringify(setResults) : "[]", + spDiff: null, + div: tournamentResult.div, + }) + .execute(); + } + + if (recalculatedCount % 10 === 0) { + logger.info( + `Processed ${recalculatedCount} tournaments with multiple starting brackets (skipped ${skippedCount})`, + ); + } + } catch (thrown) { + if (thrown instanceof Response) continue; + + logger.error(`Error processing tournament ${tournamentId}`, thrown); + } + } + + logger.info( + `Done. Recalculated ${recalculatedCount} tournaments with multiple starting brackets. Skipped ${skippedCount} tournaments.`, + ); +} + +main().catch((err) => { + logger.error("Error in backfill-tournament-result-divisions.ts", err); + process.exit(1); +});