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);
+}