Skip to content

Commit

Permalink
Better tournament standings (#1976)
Browse files Browse the repository at this point in the history
* Initial

* Fixes

* Finished
  • Loading branch information
Sendouc authored Dec 4, 2024
1 parent f7a4ee0 commit d20832a
Show file tree
Hide file tree
Showing 10 changed files with 242 additions and 64 deletions.
2 changes: 1 addition & 1 deletion app/db/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ function MapPicker({
})}
</div>
{pickersLastWonMode === mode && modes.length > 1 ? (
<div className="text-error text-xs text-center">
<div className="text-error text-xs text-center mt-2">
Can&apos;t pick the same mode team last won on
</div>
) : null}
Expand Down
44 changes: 44 additions & 0 deletions app/features/tournament-bracket/core/Progression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
68 changes: 68 additions & 0 deletions app/features/tournament-bracket/core/Progression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
42 changes: 2 additions & 40 deletions app/features/tournament-bracket/core/Tournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<number>();
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) {
Expand Down
17 changes: 17 additions & 0 deletions app/features/tournament-bracket/core/tests/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Progression.ParsedBracket[]>;
10 changes: 9 additions & 1 deletion app/features/tournament-bracket/routes/to.$id.brackets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 81 additions & 13 deletions app/features/tournament/core/Standings.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) => {
Expand All @@ -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<number>();

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<number>;
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;
}
21 changes: 13 additions & 8 deletions app/features/tournament/routes/to.$id.results.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -143,15 +144,19 @@ function MatchHistoryRow({ teamId }: { teamId: number }) {

return (
<div className="stack horizontal xs">
{teamMatches.map((match) => {
{teamMatches.map((match, i) => {
const bracketChanged =
i !== 0 && teamMatches[i - 1].bracketIdx !== match.bracketIdx;

return (
<MatchResultSquare
key={match.id}
result={match.result}
matchId={match.id}
>
{match.vsSeed}
</MatchResultSquare>
<React.Fragment key={match.id}>
{bracketChanged ? (
<div className="tournament__standings__divider" />
) : null}
<MatchResultSquare result={match.result} matchId={match.id}>
{match.vsSeed}
</MatchResultSquare>
</React.Fragment>
);
})}
</div>
Expand Down
6 changes: 6 additions & 0 deletions app/features/tournament/tournament.css
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,9 @@
align-items: center;
color: var(--text);
}

.tournament__standings__divider {
width: 5px;
background-color: var(--theme-transparent);
border-radius: var(--rounded);
}

0 comments on commit d20832a

Please sign in to comment.