From d20832acc2ab599f90170642b588643a0b1eb375 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 4 Dec 2024 23:05:56 +0200 Subject: [PATCH] Better tournament standings (#1976) * Initial * Fixes * Finished --- app/db/seed/index.ts | 2 +- .../components/MatchActionsBanPicker.tsx | 2 +- .../core/Progression.test.ts | 44 +++++++++ .../tournament-bracket/core/Progression.ts | 68 ++++++++++++++ .../tournament-bracket/core/Tournament.ts | 42 +-------- .../core/tests/test-utils.ts | 17 ++++ .../routes/to.$id.brackets.tsx | 10 +- app/features/tournament/core/Standings.ts | 94 ++++++++++++++++--- .../tournament/routes/to.$id.results.tsx | 21 +++-- app/features/tournament/tournament.css | 6 ++ 10 files changed, 242 insertions(+), 64 deletions(-) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 8c2e0e35de..2888d8347d 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1268,7 +1268,7 @@ function calendarEventWithToToolsTeams( }); } - if (Math.random() < 0.8 || id === 1) { + if (event !== "SOS" && (Math.random() < 0.8 || id === 1)) { const shuffledPairs = shuffle(availablePairs.slice()); let SZ = 0; diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx index 0ee3b19c4e..307c858b93 100644 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx +++ b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx @@ -174,7 +174,7 @@ function MapPicker({ })} {pickersLastWonMode === mode && modes.length > 1 ? ( -
+
Can't pick the same mode team last won on
) : null} diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 1e3a2c342f..a26b6aca28 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -536,3 +536,47 @@ describe("changedBracketProgression", () => { ).toEqual([]); }); }); + +describe("bracketIdxsForStandings", () => { + it("handles SE", () => { + expect( + Progression.bracketIdxsForStandings(progressions.singleElimination), + ).toEqual([0]); + }); + + it("handles RR->SE", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.roundRobinToSingleElimination, + ), + ).toEqual([1, 0]); + }); + + it("handles low ink", () => { + expect(Progression.bracketIdxsForStandings(progressions.lowInk)).toEqual([ + 3, 1, + 0, + // NOTE: 2 is omitted as it's an "intermediate" bracket + ]); + }); + + it("handles many starter brackets", () => { + expect( + Progression.bracketIdxsForStandings(progressions.manyStartBrackets), + ).toEqual([2, 0]); // NOTE, 3,1 excluded because they are not in the main progression + }); + + it("handles swiss (one group)", () => { + expect( + Progression.bracketIdxsForStandings(progressions.swissOneGroup), + ).toEqual([0]); + }); + + it("handles DE w/ underground bracket", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.doubleEliminationWithUnderground, + ), + ).toEqual([0]); // missing 1 because it's underground when DE is the source + }); +}); diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index b6888acb51..5d255a12f6 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -624,3 +624,71 @@ 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. + */ +export function bracketIdxsForStandings(progression: ParsedBracket[]) { + const bracketsToConsider = bracketsReachableFrom(0, progression); + + const withoutIntermediateBrackets = bracketsToConsider.filter( + (bracket, bracketIdx) => { + if (bracketIdx === 0) return true; + + return progression.every( + (b) => !b.sources?.some((s) => s.bracketIdx === bracket), + ); + }, + ); + + const withoutUnderground = withoutIntermediateBrackets.filter( + (bracketIdx) => { + const sources = progression[bracketIdx].sources; + + if (!sources) return true; + + return !sources.some( + (source) => + progression[source.bracketIdx].type === "double_elimination", + ); + }, + ); + + return withoutUnderground.sort((a, b) => { + const minSourcedPlacementA = Math.min( + ...(progression[a].sources?.flatMap((s) => s.placements) ?? [ + Number.POSITIVE_INFINITY, + ]), + ); + const minSourcedPlacementB = Math.min( + ...(progression[b].sources?.flatMap((s) => s.placements) ?? [ + Number.POSITIVE_INFINITY, + ]), + ); + + if (minSourcedPlacementA === minSourcedPlacementB) { + return a - b; + } + + return minSourcedPlacementA - minSourcedPlacementB; + }); +} + +function bracketsReachableFrom( + bracketIdx: number, + progression: ParsedBracket[], +): number[] { + const result = [bracketIdx]; + + for (const [newBracketIdx, bracket] of progression.entries()) { + if (!bracket.sources) continue; + + for (const source of bracket.sources) { + if (source.bracketIdx === bracketIdx) { + result.push(...bracketsReachableFrom(newBracketIdx, progression)); + } + } + } + + return result; +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 980ca36472..98eafb3541 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -5,6 +5,7 @@ import type { } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; +import * as Standings from "~/features/tournament/core/Standings"; import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { Match, Stage } from "~/modules/brackets-model"; @@ -671,46 +672,7 @@ export class Tournament { } get standings() { - if (this.brackets.length === 1) { - return this.brackets[0].standings; - } - - for (const bracket of this.brackets) { - if (bracket.isFinals) { - const finalsStandings = bracket.standings; - - const firstStageStandings = this.brackets[0].standings; - - const uniqueFinalsPlacements = new Set(); - const firstStageWithoutFinalsParticipants = firstStageStandings.filter( - (firstStageStanding) => { - const isFinalsParticipant = finalsStandings.some( - (finalsStanding) => - finalsStanding.team.id === firstStageStanding.team.id, - ); - - if (isFinalsParticipant) { - uniqueFinalsPlacements.add(firstStageStanding.placement); - } - - return !isFinalsParticipant; - }, - ); - - return [ - ...finalsStandings, - ...firstStageWithoutFinalsParticipants.filter( - // handle edge case where teams didn't check in to the final stage despite being qualified - // although this would bug out if all teams of certain placement fail to check in - // but probably that should not happen too likely - (p) => !uniqueFinalsPlacements.has(p.placement), - ), - ]; - } - } - - logger.warn("Standings not found"); - return []; + return Standings.tournamentStandings(this); } canFinalize(user: OptionalIdObject) { diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 8d61160f14..a3b7ea9ee8 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -262,4 +262,21 @@ export const progressions = { }, }, ], + doubleEliminationWithUnderground: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "double_elimination", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "double_elimination", + name: "Underground", + sources: [ + { + bracketIdx: 0, + placements: [-1, -2], + }, + ], + }, + ], } satisfies Record; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 75ad3f8117..6a406c96f5 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -688,7 +688,15 @@ function BracketNav({ }) { const tournament = useTournament(); - if (tournament.ctx.settings.bracketProgression.length < 2) return null; + const shouldRender = () => { + const brackets = tournament.ctx.isFinalized + ? tournament.brackets.filter((b) => !b.preview) + : tournament.ctx.settings.bracketProgression; + + return brackets.length > 1; + }; + + if (!shouldRender()) return null; const visibleBrackets = tournament.ctx.settings.bracketProgression.filter( // an underground bracket was never played despite being in the format diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index e092fddf3c..5031d2e004 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -1,4 +1,5 @@ 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 { removeDuplicates } from "~/utils/arrays"; @@ -41,20 +42,23 @@ export function matchesPlayed({ tournament, teamId, }: { tournament: Tournament; teamId: number }) { - // not considering underground brackets - const brackets = tournament.brackets.filter( - (b) => - !b.sources || b.sources.some((source) => source.placements.includes(1)), - ); + const brackets = Progression.bracketIdxsForStandings( + tournament.ctx.settings.bracketProgression, + ) + .reverse() + .map((bracketIdx) => tournament.bracketByIdx(bracketIdx)!); - const matches = brackets.flatMap((bracket) => - bracket.data.match.filter( - (match) => - match.opponent1 && - match.opponent2 && - (match.opponent1?.id === teamId || match.opponent2?.id === teamId) && - (match.opponent1.result === "win" || match.opponent2?.result === "win"), - ), + const matches = brackets.flatMap((bracket, bracketIdx) => + bracket.data.match + .filter( + (match) => + match.opponent1 && + match.opponent2 && + (match.opponent1?.id === teamId || match.opponent2?.id === teamId) && + (match.opponent1.result === "win" || + match.opponent2?.result === "win"), + ) + .map((match) => ({ ...match, bracketIdx })), ); return matches.map((match) => { @@ -74,6 +78,70 @@ export function matchesPlayed({ vsSeed: team?.seed ?? 0, // defensive fallback result: result ?? "win", + bracketIdx: match.bracketIdx, }; }); } + +export function tournamentStandings(tournament: Tournament): Standing[] { + const bracketIdxs = Progression.bracketIdxsForStandings( + tournament.ctx.settings.bracketProgression, + ); + + const result: Standing[] = []; + const alreadyIncludedTeamIds = new Set(); + + for (const bracketIdx of bracketIdxs) { + const bracket = tournament.bracketByIdx(bracketIdx); + if (!bracket) continue; + + const standings = standingsToMergeable({ + alreadyIncludedTeamIds, + standings: bracket.standings, + teamsAboveCount: alreadyIncludedTeamIds.size, + }); + result.push(...standings); + + for (const teamId of bracket.participantTournamentTeamIds) { + alreadyIncludedTeamIds.add(teamId); + } + for (const teamId of bracket.teamsPendingCheckIn ?? []) { + alreadyIncludedTeamIds.add(teamId); + } + } + + return result; +} + +function standingsToMergeable< + T extends { team: { id: number }; placement: number }, +>({ + alreadyIncludedTeamIds, + standings, + teamsAboveCount, +}: { + alreadyIncludedTeamIds: Set; + standings: T[]; + teamsAboveCount: number; +}) { + const result: T[] = []; + + const filtered = standings.filter( + (standing) => !alreadyIncludedTeamIds.has(standing.team.id), + ); + + let placement = teamsAboveCount + 1; + + for (const [i, standing] of filtered.entries()) { + const placementChanged = + i !== 0 && standing.placement !== filtered[i - 1].placement; + + if (placementChanged) { + placement = teamsAboveCount + i + 1; + } + + result.push({ ...standing, placement }); + } + + return result; +} diff --git a/app/features/tournament/routes/to.$id.results.tsx b/app/features/tournament/routes/to.$id.results.tsx index b792636f96..d53ca82874 100644 --- a/app/features/tournament/routes/to.$id.results.tsx +++ b/app/features/tournament/routes/to.$id.results.tsx @@ -1,5 +1,6 @@ import { Link } from "@remix-run/react"; import clsx from "clsx"; +import * as React from "react"; import { Avatar } from "~/components/Avatar"; import { Flag } from "~/components/Flag"; import { InfoPopover } from "~/components/InfoPopover"; @@ -143,15 +144,19 @@ function MatchHistoryRow({ teamId }: { teamId: number }) { return (
- {teamMatches.map((match) => { + {teamMatches.map((match, i) => { + const bracketChanged = + i !== 0 && teamMatches[i - 1].bracketIdx !== match.bracketIdx; + return ( - - {match.vsSeed} - + + {bracketChanged ? ( +
+ ) : null} + + {match.vsSeed} + + ); })}
diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index 2b77b207e9..90a02081c8 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -645,3 +645,9 @@ align-items: center; color: var(--text); } + +.tournament__standings__divider { + width: 5px; + background-color: var(--theme-transparent); + border-radius: var(--rounded); +}