Many starting brackets standings (#2611)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2025-11-03 21:30:22 +02:00 committed by GitHub
parent 2c25ef6561
commit 9fc30a7624
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 793 additions and 159 deletions

View File

@ -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 {

View File

@ -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}`;

View File

@ -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(

View File

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

View File

@ -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<Tables["Skill"], "mu" | "sigma">;
@ -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,
});
}
}

View File

@ -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<string, never>;
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();
});
});

View File

@ -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) => {

View File

@ -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,
});
}

View File

@ -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<number>();
@ -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,

View File

@ -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 (
<div className="text-center text-lg font-semi-bold text-lighter">
No team finished yet, check back later
</div>
);
}
if (standings.length === 0) {
return (
<div className="text-center text-lg font-semi-bold text-lighter">
No team finished yet, check back later
<div>
<ResultsTable standings={standingsResult.standings} />
</div>
);
}
return (
<SendouTabs>
<SendouTabList>
{standingsResult.standings.map(({ div }) => (
<SendouTab key={div} id={div}>
{div}
</SendouTab>
))}
</SendouTabList>
{standingsResult.standings.map(({ div, standings }) => (
<SendouTabPanel key={div} id={div}>
{standings.length === 0 ? (
<div className="text-center text-lg font-semi-bold text-lighter">
No team finished yet, check back later
</div>
) : (
<ResultsTable standings={standings} />
)}
</SendouTabPanel>
))}
</SendouTabs>
);
}
function ResultsTable({ standings }: { standings: Standing[] }) {
const tournament = useTournament();
let lastRenderedPlacement = 0;
let rowDarkerBg = false;
return (
<div>
<Table>
<thead>
<tr>
<th>Standing</th>
<th>Team</th>
<th>Roster</th>
<th>Seed</th>
{tournament.ctx.isFinalized ? (
<th
className="stack horizontal sm items-center"
data-testid="spr-header"
>
SPR{" "}
<InfoPopover tiny>
<a
href={SPR_INFO_URL}
target="_blank"
rel="noopener noreferrer"
<Table>
<thead>
<tr>
<th>Standing</th>
<th>Team</th>
<th>Roster</th>
<th>Seed</th>
{tournament.ctx.isFinalized ? (
<th
className="stack horizontal sm items-center"
data-testid="spr-header"
>
SPR{" "}
<InfoPopover tiny>
<a
href={SPR_INFO_URL}
target="_blank"
rel="noopener noreferrer"
>
Seed Performance Rating
</a>
</InfoPopover>
</th>
) : null}
<th>Matches</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={standing.team.id}
className={rowDarkerBg ? "bg-darker-transparent" : undefined}
>
<td className="text-md">
{typeof placement === "number" ? (
<Placement placement={placement} size={36} />
) : null}{" "}
</td>
<td>
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament__standings__team-name"
data-testid="result-team-name"
>
{teamLogoSrc ? <Avatar size="xs" url={teamLogoSrc} /> : null}{" "}
{standing.team.name}
</Link>
</td>
<td>
{standing.team.members.map((player) => (
<div
key={player.userId}
className="stack xxs horizontal items-center"
>
Seed Performance Rating
</a>
</InfoPopover>
</th>
) : null}
<th>Matches</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={standing.team.id}
className={rowDarkerBg ? "bg-darker-transparent" : undefined}
>
<td className="text-md">
{typeof placement === "number" ? (
<Placement placement={placement} size={36} />
) : null}{" "}
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
{player.username}
</div>
))}
</td>
<td className="text-sm">{standing.team.seed}</td>
{tournament.ctx.isFinalized ? (
<td className="text-sm">
{spr > 0 ? "+" : ""}
{spr}
</td>
<td>
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament__standings__team-name"
data-testid="result-team-name"
>
{teamLogoSrc ? (
<Avatar size="xs" url={teamLogoSrc} />
) : null}{" "}
{standing.team.name}
</Link>
</td>
<td>
{standing.team.members.map((player) => (
<div
key={player.userId}
className="stack xxs horizontal items-center"
>
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
{player.username}
</div>
))}
</td>
<td className="text-sm">{standing.team.seed}</td>
{tournament.ctx.isFinalized ? (
<td className="text-sm">
{spr > 0 ? "+" : ""}
{spr}
</td>
) : null}
<td>
<MatchHistoryRow teamId={standing.team.id} />
</td>
</tr>
);
})}
</tbody>
</Table>
</div>
) : null}
<td>
<MatchHistoryRow teamId={standing.team.id} />
</td>
</tr>
);
})}
</tbody>
</Table>
);
}

View File

@ -107,7 +107,9 @@ function StatSquares({
const data = useLoaderData<typeof loader>();
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")}
</div>
) : null}
{standingsResult.type === "multi" ? (
<div className="tournament__team__stat__sub">
{
standingsResult.standings.find((s) =>
s.standings.some((s) => s.team.id === data.tournamentTeamId),
)?.div
}
</div>
) : null}
</div>
</div>
);

View File

@ -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");
});
});

View File

@ -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;
}

View File

@ -483,6 +483,7 @@ export function findResultsByUserId(
"CalendarEventResultTeam.placement",
"CalendarEvent.participantCount",
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
sql<string | null>`null`.as("div"),
sql<string | null>`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"])

View File

@ -124,6 +124,9 @@ export function UserResultsTable({
>
{result.eventName}
</Link>
{result.div ? (
<span className="text-lighter">({result.div})</span>
) : null}
</>
) : null}
</div>

View File

@ -34,7 +34,9 @@ export default function UserResultsPage() {
<div className="stack lg">
<div className="stack horizontal justify-between items-center">
<h2 className="text-lg">
{showAll ? t("results.title") : t("results.highlights")}
{showAll || !data.hasHighlightedResults
? t("results.title")
: t("results.highlights")}
</h2>
{user?.id === layoutData.user.id ? (
<LinkButton

View File

@ -0,0 +1,5 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "TournamentResult" add "div" text`).run();
})();
}

View File

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