Tournament tiers (#2758)

This commit is contained in:
Kalle 2026-01-22 20:04:26 +02:00 committed by GitHub
parent dff8b39ac6
commit 60add3bfc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3245 additions and 34 deletions

View File

@ -0,0 +1,55 @@
.pill {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
border-radius: var(--rounded-sm);
padding: var(--s-0-5) var(--s-1-5);
display: grid;
place-items: center;
min-width: 24px;
text-align: center;
}
.tierX {
background-color: hsl(45, 100%, 30%);
color: hsl(45, 100%, 90%);
}
.tierSPlus {
background-color: hsl(280, 60%, 35%);
color: hsl(280, 80%, 90%);
}
.tierS {
background-color: hsl(280, 50%, 40%);
color: hsl(280, 70%, 92%);
}
.tierAPlus {
background-color: hsl(210, 60%, 35%);
color: hsl(210, 80%, 90%);
}
.tierA {
background-color: hsl(210, 50%, 40%);
color: hsl(210, 70%, 92%);
}
.tierBPlus {
background-color: hsl(140, 45%, 32%);
color: hsl(140, 60%, 90%);
}
.tierB {
background-color: hsl(140, 35%, 38%);
color: hsl(140, 50%, 92%);
}
.tierCPlus {
background-color: hsl(0, 0%, 35%);
color: hsl(0, 0%, 90%);
}
.tierC {
background-color: hsl(0, 0%, 40%);
color: hsl(0, 0%, 92%);
}

View File

@ -0,0 +1,43 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { tierNumberToName } from "~/features/tournament/core/tiering";
import styles from "./TierPill.module.css";
const TIER_STYLE_CLASS: Record<number, string> = {
1: styles.tierX,
2: styles.tierSPlus,
3: styles.tierS,
4: styles.tierAPlus,
5: styles.tierA,
6: styles.tierBPlus,
7: styles.tierB,
8: styles.tierCPlus,
9: styles.tierC,
};
export function TierPill({
tier,
isTentative = false,
}: {
tier: number;
isTentative?: boolean;
}) {
const { t } = useTranslation();
const tierName = tierNumberToName(tier);
const tierClass = TIER_STYLE_CLASS[tier] ?? "";
const displayName = isTentative ? `~${tierName}` : tierName;
return (
<div
className={clsx(styles.pill, tierClass)}
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
title={
isTentative
? t("tier.tentative", { tierName })
: t("tier.confirmed", { tierName })
}
>
{displayName}
</div>
);
}

View File

@ -2748,9 +2748,23 @@ async function organization() {
roleDisplayName: null,
},
],
series: [],
series: [
{
name: "PICNIC",
description: "PICNIC tournament series",
showLeaderboard: false,
},
],
badges: [],
});
sql
.prepare(
`UPDATE "TournamentOrganizationSeries"
SET "tierHistory" = '[3, 4, 3]'
WHERE "organizationId" = 1 AND "name" = 'PICNIC'`,
)
.run();
}
function liveStreams() {

View File

@ -13,6 +13,7 @@ import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ParticipantResult } from "~/modules/brackets-model";
@ -497,6 +498,8 @@ export interface Tournament {
isFinalized: Generated<DBBoolean>;
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
seedingSnapshot: JSONColumnTypeNullable<SeedingSnapshot>;
/** Tournament tier based on top teams' skill. 1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C */
tier: TournamentTierNumber | null;
}
export interface SeedingSnapshot {
@ -766,6 +769,7 @@ export interface TournamentOrganizationSeries {
description: string | null;
substringMatches: JSONColumnType<string[]>;
showLeaderboard: Generated<number>;
tierHistory: JSONColumnTypeNullable<TournamentTierNumber[]>;
}
export interface TournamentBracketProgressionOverride {

View File

@ -8,6 +8,7 @@ import { SEED_VARIATIONS } from "~/features/api-private/constants";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server";
import { cache } from "~/utils/cache.server";
import { parseRequestPayload } from "~/utils/remix.server";
@ -52,6 +53,7 @@ export const action: ActionFunction = async ({ request }) => {
cache.clear();
await refreshBannedCache();
await refreshSendouQInstance();
await refreshTentativeTiersCache();
return Response.json(null);
};

View File

@ -1,3 +1,4 @@
import { sub } from "date-fns";
import type {
Expression,
ExpressionBuilder,
@ -17,6 +18,7 @@ import type {
} from "~/db/tables";
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import {
databaseTimestampNow,
databaseTimestampToDate,
@ -168,9 +170,11 @@ function findAllBetweenTwoTimestampsQuery({
.select((eb) => [
"CalendarEvent.id as eventId",
"CalendarEvent.authorId",
"CalendarEvent.organizationId",
"Tournament.id as tournamentId",
"Tournament.settings as tournamentSettings",
"Tournament.mapPickingStyle",
"Tournament.tier",
"CalendarEvent.name",
"CalendarEvent.tags",
"CalendarEventDate.startTime",
@ -227,6 +231,16 @@ function findAllBetweenTwoTimestampsMapped(
? (row.tags.split(",") as CalendarEvent["tags"])
: [];
const isPastEvent =
databaseTimestampToDate(row.startTime) < sub(new Date(), { days: 1 });
const tentativeTier =
row.tier === null &&
row.organizationId !== null &&
row.tournamentId !== null &&
!isPastEvent
? getTentativeTier(row.organizationId, row.name)
: null;
return {
at: databaseTimestampToJavascriptTimestamp(row.startTime),
type: "calendar",
@ -261,6 +275,8 @@ function findAllBetweenTwoTimestampsMapped(
isTest: row.tournamentSettings.isTest ?? false,
})
: null,
tier: row.tier ?? null,
tentativeTier,
};
},
);

View File

@ -12,6 +12,10 @@ interface CommonEvent {
url: string;
/** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */
isRanked: boolean | null;
/** Tournament tier (1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C). Null if not tiered. */
tier: number | null;
/** Tentative tier prediction based on series history. Displayed with ~ prefix. */
tentativeTier: number | null;
modes: Array<ModeShortWithSpecial> | null;
organization: {
name: string;

View File

@ -57,10 +57,17 @@
font-weight: var(--semi-bold);
color: var(--text-lighter);
display: block;
text-align: center;
}
.nameRow {
display: flex;
justify-content: center;
align-items: center;
gap: var(--s-2);
}
.name {
text-align: center;
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
white-space: nowrap;
@ -69,6 +76,10 @@
overflow: hidden;
}
.nameWithTier {
max-width: 165px;
}
.teamCount {
font-size: var(--fonts-xxs);
font-weight: var(--bold);

View File

@ -7,6 +7,7 @@ import { Flag } from "~/components/Flag";
import { Image, ModeImage } from "~/components/Image";
import { TrophyIcon } from "~/components/icons/Trophy";
import { UsersIcon } from "~/components/icons/Users";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
@ -79,25 +80,37 @@ export function TournamentCard({
) : null}
</div>
<div
className={clsx(styles.name, {
className={clsx(styles.nameRow, {
"mt-3": !isHostedOnSendouInk,
"mt-1": isHostedOnSendouInk,
})}
>
{tournament.name}{" "}
{isShowcase ? (
<time
className={clsx(styles.time, {
invisible: !isMounted,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
>
{time()}
</time>
<div
className={clsx(styles.name, {
[styles.nameWithTier]:
tournament.tier || tournament.tentativeTier,
})}
>
{tournament.name}
</div>
{tournament.tier ? (
<TierPill tier={tournament.tier} />
) : tournament.tentativeTier ? (
<TierPill tier={tournament.tentativeTier} isTentative />
) : null}
</div>
{isShowcase ? (
<time
className={clsx(styles.time, {
invisible: !isMounted,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
>
{time()}
</time>
) : null}
{isCalendar ? (
<div className="stack sm items-center my-2">
<Tags tags={tournament.tags} small centered />

View File

@ -12,6 +12,8 @@ function makeEvent(
at: Date.now(),
id: 1,
isRanked: null,
tier: null,
tentativeTier: null,
tags: [],
modes: ["SZ"],
teamsCount: 2,

View File

@ -5,6 +5,7 @@ import {
getBracketProgressionLabel,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import {
databaseTimestampToDate,
@ -169,7 +170,6 @@ async function cachedTournaments() {
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
const tournaments = await TournamentRepository.forShowcase();
const mapped = tournaments.map(mapTournamentFromDB);
return deleteExtraResults(mapped);
@ -276,6 +276,13 @@ function mapTournamentFromDB(
): ShowcaseCalendarEvent {
const highestDivWinners = resolveHighestDivisionWinners(tournament);
const tentativeTier =
tournament.tier === null &&
tournament.organizationId !== null &&
!tournament.firstPlacers.length
? getTentativeTier(tournament.organizationId, tournament.name)
: null;
return {
type: "showcase",
url: tournamentPage(tournament.id),
@ -297,8 +304,10 @@ function mapTournamentFromDB(
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
isTest: tournament.settings.isTest ?? false,
}),
tier: tournament.tier ?? null,
tentativeTier,
hidden: Boolean(tournament.hidden),
modes: null, // no need to show modes for front page, maybe could in the future?
modes: null,
firstPlacer:
highestDivWinners.length > 0
? {

View File

@ -28,6 +28,8 @@ import {
type TournamentBadgeReceivers,
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
@ -106,6 +108,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
finalizeTournament(tournamentId);
}
await updateSeriesTierHistory(tournament);
if (tournament.ranked) {
try {
refreshUserSkills(season!);
@ -177,3 +181,25 @@ async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) {
logger.error("Error notifying badge receivers", error);
}
}
async function updateSeriesTierHistory(tournament: Tournament) {
const organizationId = tournament.ctx.organization?.id;
if (!organizationId) return;
const tier = tournament.ctx.tier;
if (tier === null) return;
try {
await TournamentOrganizationRepository.updateSeriesTierHistory({
organizationId,
eventName: tournament.ctx.name,
newTier: tier,
});
await refreshTentativeTiersCache();
logger.info(
`Updated series tier history for tournament ${tournament.ctx.id} with tier ${tier}`,
);
} catch (error) {
logger.error("Error updating series tier history", error);
}
}

View File

@ -3,6 +3,10 @@ import { sql } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import {
calculateTournamentTierFromTeams,
MIN_TEAMS_FOR_TIERING,
} from "~/features/tournament/core/tiering";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@ -132,6 +136,24 @@ export const action: ActionFunction = async ({ params, request }) => {
});
}
if (data.bracketIdx === 0 && seeding.length >= MIN_TEAMS_FOR_TIERING) {
const checkedInTeams = tournament.ctx.teams
.filter((team) => seeding.includes(team.id))
.map((team) => ({ avgOrdinal: team.avgSeedingSkillOrdinal }));
const { tierNumber } = calculateTournamentTierFromTeams(
checkedInTeams,
seeding.length,
);
if (tierNumber !== null) {
await TournamentRepository.updateTournamentTier({
tournamentId: tournament.ctx.id,
tier: tierNumber,
});
}
}
if (!tournament.isTest) {
notify({
userIds: seeding.flatMap((tournamentTeamId) =>

View File

@ -1,4 +1,5 @@
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { isAdmin } from "~/modules/permissions/utils";
import { notFoundIfFalsy } from "~/utils/remix.server";
@ -54,10 +55,16 @@ function dataMapped({
isAdmin(user);
const revealInfo = tournamentHasStarted || isOrganizer;
const tentativeTier =
!ctx.tier && ctx.organization?.id
? getTentativeTier(ctx.organization.id, ctx.name)
: null;
return {
data,
ctx: {
...ctx,
tentativeTier,
teams: ctx.teams.map((team) => {
const isOwnTeam = team.members.some(
(member) => member.userId === user?.id,

View File

@ -6834,6 +6834,8 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
id: 815,
eventId: 2614,
parentTournamentId: null,
tier: null,
tentativeTier: null,
discordUrl: "https://discord.gg/F7RaNUR",
tags: "LOW",
settings: {

View File

@ -1934,6 +1934,8 @@ export const SWIM_OR_SINK_167 = (
ctx: {
id: 672,
parentTournamentId: null,
tier: null,
tentativeTier: null,
eventId: 2425,
discordUrl: "https://discord.gg/F7RaNUR",
tags: null,

View File

@ -288,6 +288,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
ctx: {
id: 891,
eventId: 2698,
tier: null,
tentativeTier: null,
discordUrl: "https://discord.gg/A6NW3VCHRh",
tags: "REGION,SZ",
settings: {

View File

@ -1396,6 +1396,8 @@ export const PADDLING_POOL_257 = () =>
ctx: {
id: 27,
organization: null,
tier: null,
tentativeTier: null,
parentTournamentId: null,
tags: null,
eventId: 1352,
@ -8001,6 +8003,8 @@ export const PADDLING_POOL_255 = () =>
ctx: {
id: 18,
organization: null,
tier: null,
tentativeTier: null,
parentTournamentId: null,
tags: null,
eventId: 1286,
@ -14946,6 +14950,8 @@ export const IN_THE_ZONE_32 = ({
id: 11,
parentTournamentId: null,
organization: null,
tier: null,
tentativeTier: null,
tags: null,
eventId: 1134,
bracketProgressionOverrides: [],

View File

@ -63,6 +63,8 @@ export const testTournament = ({
tags: null,
description: null,
organization: null,
tier: null,
tentativeTier: null,
parentTournamentId: null,
rules: null,
logoUrl: "/test.png",

View File

@ -3,6 +3,10 @@ import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import {
type TournamentTierNumber,
updateTierHistory,
} from "~/features/tournament/core/tiering";
import {
databaseTimestampNow,
databaseTimestampToDate,
@ -92,6 +96,7 @@ export async function findBySlug(slug: string) {
"TournamentOrganizationSeries.substringMatches",
"TournamentOrganizationSeries.showLeaderboard",
"TournamentOrganizationSeries.description",
"TournamentOrganizationSeries.tierHistory",
])
.whereRef(
"TournamentOrganizationSeries.organizationId",
@ -553,3 +558,43 @@ export function updateIsEstablished(
.where("id", "=", organizationId)
.execute();
}
export function findAllSeriesWithTierHistory() {
return db
.selectFrom("TournamentOrganizationSeries")
.select(["organizationId", "substringMatches", "tierHistory"])
.execute();
}
export async function updateSeriesTierHistory({
organizationId,
eventName,
newTier,
}: {
organizationId: number;
eventName: string;
newTier: TournamentTierNumber;
}) {
const series = await db
.selectFrom("TournamentOrganizationSeries")
.select(["id", "substringMatches", "tierHistory"])
.where("organizationId", "=", organizationId)
.execute();
const eventNameLower = eventName.toLowerCase();
const matchingSeries = series.find((s) =>
s.substringMatches.some((match) =>
eventNameLower.includes(match.toLowerCase()),
),
);
if (!matchingSeries) return;
const newTierHistory = updateTierHistory(matchingSeries.tierHistory, newTier);
await db
.updateTable("TournamentOrganizationSeries")
.set({ tierHistory: JSON.stringify(newTierHistory) })
.where("id", "=", matchingSeries.id)
.execute();
}

View File

@ -0,0 +1,49 @@
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
interface SeriesMatch {
substringMatches: string[];
tentativeTier: number;
}
async function loadCache(): Promise<Map<number, SeriesMatch[]>> {
const rows =
await TournamentOrganizationRepository.findAllSeriesWithTierHistory();
const result = new Map<number, SeriesMatch[]>();
for (const row of rows) {
if (!row.tierHistory?.length) continue;
const tentativeTier = calculateTentativeTier(row.tierHistory);
if (tentativeTier === null) continue;
const existing = result.get(row.organizationId) ?? [];
existing.push({
substringMatches: row.substringMatches,
tentativeTier,
});
result.set(row.organizationId, existing);
}
return result;
}
let cache = await loadCache();
export function getTentativeTier(
orgId: number,
tournamentName: string,
): number | null {
const seriesList = cache.get(orgId);
if (!seriesList) return null;
const nameLower = tournamentName.toLowerCase();
const match = seriesList.find((s) =>
s.substringMatches.some((m) => nameLower.includes(m.toLowerCase())),
);
return match?.tentativeTier ?? null;
}
export async function refreshTentativeTiersCache(): Promise<void> {
cache = await loadCache();
}

View File

@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { getUser } from "~/features/auth/core/user.server";
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
@ -52,6 +53,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
description: series.description,
page,
leaderboard: series.showLeaderboard ? leaderboard : null,
tentativeTier: series.tierHistory
? calculateTentativeTier(series.tierHistory)
: null,
...rest,
};
};

View File

@ -18,6 +18,7 @@ import { UsersIcon } from "~/components/icons/Users";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { SendouForm } from "~/form/SendouForm";
@ -329,7 +330,12 @@ function SeriesHeader({
/>
) : null}
<div>
<h2 className="text-lg">{series.name}</h2>
<div className="stack horizontal sm items-center">
<h2 className="text-lg">{series.name}</h2>
{series.tentativeTier ? (
<TierPill tier={series.tentativeTier} />
) : null}
</div>
{series.established ? (
<div className="text-lighter text-italic text-xs">
{t("org:events.established.short")}{" "}

View File

@ -22,6 +22,7 @@ import {
userChatNameColor,
} from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import type { TournamentTierNumber } from "./core/tiering";
export type FindById = NonNullable<Unwrapped<typeof findById>>;
export async function findById(id: number) {
@ -51,6 +52,7 @@ export async function findById(id: number) {
"Tournament.mapPickingStyle",
"Tournament.rules",
"Tournament.parentTournamentId",
"Tournament.tier",
"CalendarEvent.name",
"CalendarEvent.description",
"CalendarEventDate.startTime",
@ -464,8 +466,10 @@ export function forShowcase() {
.select((eb) => [
"Tournament.id",
"Tournament.settings",
"Tournament.tier",
"CalendarEvent.authorId",
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startTime",
"CalendarEvent.hidden",
eb
@ -1234,3 +1238,17 @@ export function updateTeamSeeds({
.execute();
});
}
export function updateTournamentTier({
tournamentId,
tier,
}: {
tournamentId: number;
tier: TournamentTierNumber;
}) {
return db
.updateTable("Tournament")
.set({ tier })
.where("id", "=", tournamentId)
.execute();
}

View File

@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import {
calculateAdjustedScore,
calculateTentativeTier,
calculateTierNumber,
calculateTournamentTierFromTeams,
MIN_TEAMS_FOR_TIERING,
TIER_HISTORY_LENGTH,
TIER_THRESHOLDS,
TIER_TO_NUMBER,
type TournamentTierNumber,
tierNumberToName,
updateTierHistory,
} from "./tiering";
describe("calculateTierNumber", () => {
it("returns null for null input", () => {
expect(calculateTierNumber(null)).toBe(null);
});
it("returns X tier (1) for scores >= 32", () => {
expect(calculateTierNumber(32)).toBe(1);
expect(calculateTierNumber(35)).toBe(1);
expect(calculateTierNumber(100)).toBe(1);
});
it("returns A tier (5) for scores >= 21 and < 24", () => {
expect(calculateTierNumber(21)).toBe(5);
expect(calculateTierNumber(23.9)).toBe(5);
});
it("returns C tier (9) for scores < 5", () => {
expect(calculateTierNumber(4.9)).toBe(9);
expect(calculateTierNumber(0)).toBe(9);
expect(calculateTierNumber(-10)).toBe(9);
});
});
describe("tierNumberToName", () => {
it("converts all tier numbers correctly", () => {
expect(tierNumberToName(1)).toBe("X");
expect(tierNumberToName(2)).toBe("S+");
expect(tierNumberToName(3)).toBe("S");
expect(tierNumberToName(4)).toBe("A+");
expect(tierNumberToName(5)).toBe("A");
expect(tierNumberToName(6)).toBe("B+");
expect(tierNumberToName(7)).toBe("B");
expect(tierNumberToName(8)).toBe("C+");
expect(tierNumberToName(9)).toBe("C");
});
it("throws for invalid tier numbers", () => {
expect(() => tierNumberToName(0)).toThrow("Invalid tier number: 0");
expect(() => tierNumberToName(10)).toThrow("Invalid tier number: 10");
});
});
describe("calculateAdjustedScore", () => {
it("returns raw score when at X-tier threshold (no bonus)", () => {
expect(calculateAdjustedScore(32, 50)).toBe(32);
});
it("returns raw score when below MIN_TEAMS_FOR_TIERING", () => {
expect(calculateAdjustedScore(20, MIN_TEAMS_FOR_TIERING)).toBe(20);
});
it("applies size bonus for lower skill tournaments with many teams", () => {
const rawScore = 20;
const teamCount = 50;
const adjusted = calculateAdjustedScore(rawScore, teamCount);
expect(adjusted).toBeGreaterThan(rawScore);
});
it("applies larger bonus at lower skill levels", () => {
const teamCount = 50;
const bonusAtScore20 = calculateAdjustedScore(20, teamCount) - 20;
const bonusAtScore10 = calculateAdjustedScore(10, teamCount) - 10;
expect(bonusAtScore10).toBeGreaterThan(bonusAtScore20);
});
it("applies larger bonus for more teams", () => {
const rawScore = 15;
const bonusAt30Teams = calculateAdjustedScore(rawScore, 30) - rawScore;
const bonusAt50Teams = calculateAdjustedScore(rawScore, 50) - rawScore;
expect(bonusAt50Teams).toBeGreaterThan(bonusAt30Teams);
});
it("calculates correct bonus for specific case", () => {
const rawScore = 0;
const teamCount = 18;
const teamsAboveMin = teamCount - MIN_TEAMS_FOR_TIERING;
const maxBonus = 1.5 * (teamsAboveMin / 10);
const adjusted = calculateAdjustedScore(rawScore, teamCount);
expect(adjusted).toBeCloseTo(rawScore + maxBonus, 5);
});
});
describe("calculateTournamentTierFromTeams", () => {
it("returns null tier for tournaments below MIN_TEAMS_FOR_TIERING", () => {
const teams = [{ avgOrdinal: 30 }, { avgOrdinal: 28 }];
const result = calculateTournamentTierFromTeams(teams, 7);
expect(result.tierNumber).toBe(null);
expect(result.rawScore).toBe(null);
expect(result.adjustedScore).toBe(null);
});
it("returns null tier when all teams have null ordinals", () => {
const teams = [{ avgOrdinal: null }, { avgOrdinal: null }];
const result = calculateTournamentTierFromTeams(teams, 10);
expect(result.tierNumber).toBe(null);
});
it("returns null tier for empty teams array with sufficient count", () => {
const result = calculateTournamentTierFromTeams([], 10);
expect(result.tierNumber).toBe(null);
});
it("calculates tier from top 8 teams by ordinal", () => {
const teams = [
{ avgOrdinal: 35 },
{ avgOrdinal: 34 },
{ avgOrdinal: 33 },
{ avgOrdinal: 32 },
{ avgOrdinal: 31 },
{ avgOrdinal: 30 },
{ avgOrdinal: 29 },
{ avgOrdinal: 28 },
{ avgOrdinal: 10 },
{ avgOrdinal: 5 },
];
const result = calculateTournamentTierFromTeams(teams, 10);
expect(result.rawScore).toBeCloseTo(31.5, 5);
expect(result.tierNumber).toBe(TIER_TO_NUMBER["S+"]);
});
it("ignores teams with null ordinals when calculating", () => {
const teams = [
{ avgOrdinal: 30 },
{ avgOrdinal: null },
{ avgOrdinal: 28 },
{ avgOrdinal: null },
{ avgOrdinal: 26 },
{ avgOrdinal: 24 },
{ avgOrdinal: 22 },
{ avgOrdinal: 20 },
{ avgOrdinal: 18 },
{ avgOrdinal: 16 },
];
const result = calculateTournamentTierFromTeams(teams, 10);
expect(result.tierNumber).not.toBe(null);
});
it("applies size bonus correctly", () => {
const teams = Array.from({ length: 50 }, () => ({ avgOrdinal: 20 }));
const result = calculateTournamentTierFromTeams(teams, 50);
expect(result.adjustedScore).toBeGreaterThan(result.rawScore!);
});
it("uses fewer than 8 teams if not enough available", () => {
const teams = [{ avgOrdinal: 30 }, { avgOrdinal: 28 }, { avgOrdinal: 26 }];
const result = calculateTournamentTierFromTeams(teams, 8);
expect(result.rawScore).toBeCloseTo(28, 5);
expect(result.tierNumber).not.toBe(null);
});
});
describe("TIER_THRESHOLDS matches TIER_TO_NUMBER", () => {
it("all tiers in TIER_THRESHOLDS have corresponding number", () => {
for (const tier of Object.keys(TIER_THRESHOLDS)) {
expect(TIER_TO_NUMBER).toHaveProperty(tier);
}
});
});
describe("calculateTentativeTier", () => {
it("returns null for empty history", () => {
expect(calculateTentativeTier([])).toBe(null);
});
it("returns the single value for history with one element", () => {
expect(calculateTentativeTier([4])).toBe(4);
});
it("returns median for odd number of elements", () => {
expect(calculateTentativeTier([5, 4, 3])).toBe(4);
expect(calculateTentativeTier([1, 2, 3, 4, 5])).toBe(3);
});
it("rounds toward lower tier (higher number) for even count", () => {
expect(calculateTentativeTier([4, 5])).toBe(5);
expect(calculateTentativeTier([3, 4])).toBe(4);
});
it("returns median for unsorted input", () => {
expect(calculateTentativeTier([3, 5, 4, 2, 6])).toBe(4);
});
it("handles duplicate values correctly", () => {
expect(calculateTentativeTier([4, 4, 4])).toBe(4);
expect(calculateTentativeTier([5, 5, 4, 4])).toBe(5);
});
});
describe("updateTierHistory", () => {
it("creates new history from null", () => {
expect(updateTierHistory(null, 4)).toEqual([4]);
});
it("appends to existing history", () => {
expect(updateTierHistory([5, 4], 3)).toEqual([5, 4, 3]);
});
it("keeps history at max length", () => {
const fullHistory: TournamentTierNumber[] = [1, 2, 3, 4, 5];
expect(updateTierHistory(fullHistory, 6)).toEqual([2, 3, 4, 5, 6]);
});
it("preserves history under max length", () => {
expect(updateTierHistory([4, 5], 3)).toEqual([4, 5, 3]);
});
it("slices from beginning when exceeding max length", () => {
const history: TournamentTierNumber[] = [1, 2, 3, 4, 5];
const result = updateTierHistory(history, 9);
expect(result.length).toBe(TIER_HISTORY_LENGTH);
expect(result[result.length - 1]).toBe(9);
expect(result[0]).toBe(2);
});
});

View File

@ -0,0 +1,148 @@
/**
* Tournament tier calculation based on top teams' average SeedingSkill ordinal.
* Lower tier number = better tier (like placements).
*/
export const TIER_THRESHOLDS = {
X: 32,
"S+": 29,
S: 26,
"A+": 24,
A: 21,
"B+": 15,
B: 10,
"C+": 5,
C: Number.NEGATIVE_INFINITY,
} as const;
export const TOP_TEAMS_COUNT = 8;
export const MIN_TEAMS_FOR_TIERING = 8;
export const TIER_HISTORY_LENGTH = 5;
const SIZE_BONUS = {
NO_BONUS_ABOVE: 32,
MAX_BONUS_PER_10_TEAMS: 1.5,
} as const;
export const TIER_TO_NUMBER = {
X: 1,
"S+": 2,
S: 3,
"A+": 4,
A: 5,
"B+": 6,
B: 7,
"C+": 8,
C: 9,
} as const;
const NUMBER_TO_TIER = {
1: "X",
2: "S+",
3: "S",
4: "A+",
5: "A",
6: "B+",
7: "B",
8: "C+",
9: "C",
} as const;
export type TournamentTier = keyof typeof TIER_TO_NUMBER;
export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier];
export function calculateAdjustedScore(
rawScore: number,
teamCount: number,
): number {
const scaleFactor = Math.max(
0,
(SIZE_BONUS.NO_BONUS_ABOVE - rawScore) / SIZE_BONUS.NO_BONUS_ABOVE,
);
const teamsAboveMin = Math.max(0, teamCount - MIN_TEAMS_FOR_TIERING);
const bonus =
scaleFactor * SIZE_BONUS.MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10);
return rawScore + bonus;
}
export function calculateTierNumber(
score: number | null,
): TournamentTierNumber | null {
if (score === null) return null;
const tiers = Object.entries(TIER_THRESHOLDS) as [TournamentTier, number][];
for (const [tier, threshold] of tiers) {
if (score >= threshold) return TIER_TO_NUMBER[tier];
}
return TIER_TO_NUMBER.C;
}
export function tierNumberToName(tierNumber: number): TournamentTier {
const tier = NUMBER_TO_TIER[tierNumber as TournamentTierNumber];
if (!tier) {
throw new Error(`Invalid tier number: ${tierNumber}`);
}
return tier;
}
export function calculateTournamentTierFromTeams(
teams: Array<{ avgOrdinal: number | null }>,
totalTeamCount: number,
): {
tierNumber: TournamentTierNumber | null;
rawScore: number | null;
adjustedScore: number | null;
} {
if (totalTeamCount < MIN_TEAMS_FOR_TIERING) {
return { tierNumber: null, rawScore: null, adjustedScore: null };
}
const teamsWithOrdinal = teams.filter(
(t): t is { avgOrdinal: number } => t.avgOrdinal !== null,
);
if (teamsWithOrdinal.length === 0) {
return { tierNumber: null, rawScore: null, adjustedScore: null };
}
const topTeams = teamsWithOrdinal
.sort((a, b) => b.avgOrdinal - a.avgOrdinal)
.slice(0, TOP_TEAMS_COUNT);
const rawScore =
topTeams.reduce((sum, t) => sum + t.avgOrdinal, 0) / topTeams.length;
const adjustedScore = calculateAdjustedScore(rawScore, totalTeamCount);
const tierNumber = calculateTierNumber(adjustedScore);
return { tierNumber, rawScore, adjustedScore };
}
export function calculateTentativeTier(
tierHistory: TournamentTierNumber[],
): TournamentTierNumber | null {
if (tierHistory.length === 0) return null;
const sorted = [...tierHistory].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return Math.ceil(
(sorted[mid - 1] + sorted[mid]) / 2,
) as TournamentTierNumber;
}
return sorted[mid];
}
export function updateTierHistory(
currentHistory: TournamentTierNumber[] | null,
newTier: TournamentTierNumber,
): TournamentTierNumber[] {
const history = currentHistory ?? [];
const updated = [...history, newTier];
return updated.length > TIER_HISTORY_LENGTH
? updated.slice(-TIER_HISTORY_LENGTH)
: updated;
}

View File

@ -31,6 +31,7 @@ import { MapPoolStages } from "~/components/MapPoolSelector";
import { Markdown } from "~/components/Markdown";
import { Section } from "~/components/Section";
import { SubmitButton } from "~/components/SubmitButton";
import { TierPill } from "~/components/TierPill";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
@ -139,6 +140,11 @@ export default function TournamentRegisterPage() {
Unranked
</div>
)}
{tournament.ctx.tier ? (
<TierPill tier={tournament.ctx.tier} />
) : tournament.ctx.tentativeTier && !tournament.hasStarted ? (
<TierPill tier={tournament.ctx.tentativeTier} isTentative />
) : null}
<div className="tournament__badge tournament__badge__modes">
{tournament.modesIncluded.map((mode) => (
<ModeImage key={mode} mode={mode} size={16} />

View File

@ -510,6 +510,7 @@ const baseTournamentResultsQuery = (userId: number) =>
"CalendarEvent.tournamentId",
"TournamentResult.tournamentId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId")
.where("TournamentResult.userId", "=", userId);
export function findResultsByUserId(
@ -537,6 +538,7 @@ export function findResultsByUserId(
sql`1`,
sql`0`,
]).as("isHighlight"),
sql<number | null>`null`.as("tier"),
withMaxEventStartTime(eb),
jsonArrayFrom(
eb
@ -571,6 +573,7 @@ export function findResultsByUserId(
"TournamentTeam.id as teamId",
"TournamentTeam.name as teamName",
"TournamentResult.isHighlight",
"Tournament.tier",
withMaxEventStartTime(eb),
jsonArrayFrom(
eb

View File

@ -6,6 +6,7 @@ import { SendouPopover } from "~/components/elements/Popover";
import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import {
@ -117,6 +118,7 @@ export function UserResultsTable({
>
{result.eventName}
</Link>
{result.tier ? <TierPill tier={result.tier} /> : null}
{result.div ? (
<span className="text-lighter">({result.div})</span>
) : null}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,37 @@
import {
expect,
impersonate,
navigate,
seed,
submit,
test,
} from "~/utils/playwright";
import { calendarPage, tournamentBracketsPage } from "~/utils/urls";
test.describe("Tournament tiers", () => {
test("shows tentative tier before bracket starts and confirmed tier after", async ({
page,
}) => {
await seed(page);
await navigate({ page, url: calendarPage() });
const picnicCard = page
.getByTestId("tournament-card")
.filter({ hasText: "PICNIC" });
await expect(picnicCard.getByTestId("tentative-tier")).toBeVisible();
await impersonate(page);
await navigate({ page, url: tournamentBracketsPage({ tournamentId: 1 }) });
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
await navigate({ page, url: calendarPage() });
const picnicCardAfter = page
.getByTestId("tournament-card")
.filter({ hasText: "PICNIC" });
await expect(picnicCardAfter.getByTestId("confirmed-tier")).toBeVisible();
});
});

View File

@ -328,5 +328,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -328,5 +328,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -328,5 +328,7 @@
"api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.",
"api.regenerate.button": "Regenerate token",
"api.regenerate.confirm": "Confirm Regenerate",
"api.generate": "Generate Token"
"api.generate": "Generate Token",
"tier.tentative": "Tentative {{tierName}}-tier (based on series history)",
"tier.confirmed": "{{tierName}}-tier tournament"
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -329,5 +329,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -324,5 +324,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -324,5 +324,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -328,5 +328,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -331,5 +331,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -330,5 +330,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -331,5 +331,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -324,5 +324,7 @@
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
"api.generate": "",
"tier.tentative": "",
"tier.confirmed": ""
}

View File

@ -0,0 +1,6 @@
export function up(db) {
db.prepare(/* sql */ `alter table "Tournament" add "tier" integer`).run();
db.prepare(
/* sql */ `alter table "TournamentOrganizationSeries" add "tierHistory" text`,
).run();
}

View File

@ -0,0 +1,257 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Backfill Tournament Tiers Script
*
* Run with: npx tsx scripts/backfill-tournament-tiers.ts
*
* Retroactively calculates and sets tiers for all finalized tournaments,
* then populates series tier history based on those tiers.
*/
import { sql } from "~/db/sql";
import {
calculateAdjustedScore,
calculateTierNumber,
MIN_TEAMS_FOR_TIERING,
TIER_HISTORY_LENGTH,
TOP_TEAMS_COUNT,
} from "../app/features/tournament/core/tiering";
const dryRun = process.argv.includes("--dry-run");
interface TournamentScore {
tournamentId: number;
teamCount: number;
top8AvgOrdinal: number | null;
}
interface TournamentWithOrg {
tournamentId: number;
name: string;
organizationId: number | null;
startTime: number;
}
interface Series {
id: number;
organizationId: number;
substringMatches: string;
}
function getTournamentScores(): TournamentScore[] {
const query = `
WITH TeamSkills AS (
SELECT
tt.tournamentId,
tt.id as teamId,
AVG(ss.ordinal) as avg_team_ordinal
FROM TournamentTeam tt
JOIN TournamentTeamMember ttm ON ttm.tournamentTeamId = tt.id
LEFT JOIN SeedingSkill ss ON ss.userId = ttm.userId AND ss.type = 'RANKED'
WHERE tt.droppedOut = 0
GROUP BY tt.tournamentId, tt.id
),
TeamCounts AS (
SELECT tournamentId, COUNT(*) as team_count
FROM TeamSkills
WHERE avg_team_ordinal IS NOT NULL
GROUP BY tournamentId
),
RankedTeams AS (
SELECT
ts.tournamentId,
ts.avg_team_ordinal,
tc.team_count,
ROW_NUMBER() OVER (PARTITION BY ts.tournamentId ORDER BY ts.avg_team_ordinal DESC) as rank
FROM TeamSkills ts
JOIN TeamCounts tc ON tc.tournamentId = ts.tournamentId
WHERE ts.avg_team_ordinal IS NOT NULL
),
TournamentScores AS (
SELECT
tournamentId,
AVG(avg_team_ordinal) as top8_avg_ordinal,
MAX(team_count) as team_count
FROM RankedTeams
WHERE rank <= ${TOP_TEAMS_COUNT}
GROUP BY tournamentId
)
SELECT
t.id as tournamentId,
COALESCE(ts.team_count, 0) as teamCount,
ts.top8_avg_ordinal as top8AvgOrdinal
FROM Tournament t
LEFT JOIN TournamentScores ts ON ts.tournamentId = t.id
WHERE t.isFinalized = 1
`;
return sql.prepare(query).all() as TournamentScore[];
}
function getTournamentsWithOrg(): TournamentWithOrg[] {
const query = /* sql */ `
SELECT
t.id as tournamentId,
ce.name,
ce.organizationId,
ced.startTime
FROM Tournament t
INNER JOIN CalendarEvent ce ON ce.tournamentId = t.id
INNER JOIN CalendarEventDate ced ON ced.eventId = ce.id
WHERE t.isFinalized = 1
ORDER BY ced.startTime ASC
`;
return sql.prepare(query).all() as TournamentWithOrg[];
}
function getAllSeries(): Series[] {
const query = /* sql */ `
SELECT id, organizationId, substringMatches
FROM TournamentOrganizationSeries
`;
return sql.prepare(query).all() as Series[];
}
function matchesSubstring(
eventName: string,
substringMatches: string[],
): boolean {
const eventNameLower = eventName.toLowerCase();
return substringMatches.some((match) =>
eventNameLower.includes(match.toLowerCase()),
);
}
function main() {
console.log("=== Backfilling Tournament Tiers ===\n");
if (dryRun) {
console.log("DRY RUN - no changes will be made\n");
}
const tournaments = getTournamentScores();
console.log(`Found ${tournaments.length} finalized tournaments\n`);
const updateTierStatement = sql.prepare(
/* sql */ `UPDATE "Tournament" SET tier = @tier WHERE id = @tournamentId`,
);
const tierCounts: Record<string, number> = {};
const tournamentTiers = new Map<number, number>();
let updatedCount = 0;
let skippedCount = 0;
for (const t of tournaments) {
const meetsMinTeams = t.teamCount >= MIN_TEAMS_FOR_TIERING;
let tierNumber: number | null = null;
if (t.top8AvgOrdinal !== null && meetsMinTeams) {
const adjustedScore = calculateAdjustedScore(
t.top8AvgOrdinal,
t.teamCount,
);
tierNumber = calculateTierNumber(adjustedScore);
}
if (tierNumber !== null) {
tierCounts[tierNumber] = (tierCounts[tierNumber] || 0) + 1;
tournamentTiers.set(t.tournamentId, tierNumber);
updatedCount++;
} else {
skippedCount++;
}
if (!dryRun) {
updateTierStatement.run({
tier: tierNumber,
tournamentId: t.tournamentId,
});
}
}
console.log("Tier distribution:");
const tierNames: Record<number, string> = {
1: "X",
2: "S+",
3: "S",
4: "A+",
5: "A",
6: "B+",
7: "B",
8: "C+",
9: "C",
};
for (let i = 1; i <= 9; i++) {
console.log(` ${tierNames[i]}: ${tierCounts[i] || 0}`);
}
console.log(`\nUpdated: ${updatedCount} tournaments`);
console.log(`Skipped (untiered): ${skippedCount} tournaments`);
console.log("\n=== Backfilling Series Tier History ===\n");
const allSeries = getAllSeries();
const tournamentsWithOrg = getTournamentsWithOrg();
console.log(`Found ${allSeries.length} series`);
console.log(
`Found ${tournamentsWithOrg.filter((t) => t.organizationId !== null).length} tournaments with organizations\n`,
);
const updateSeriesStatement = sql.prepare(
/* sql */ "UPDATE TournamentOrganizationSeries SET tierHistory = @tierHistory WHERE id = @seriesId",
);
const seriesByOrg = new Map<number, Series[]>();
for (const series of allSeries) {
const existing = seriesByOrg.get(series.organizationId) ?? [];
existing.push(series);
seriesByOrg.set(series.organizationId, existing);
}
let seriesUpdatedCount = 0;
let seriesSkippedCount = 0;
for (const [organizationId, orgSeries] of seriesByOrg.entries()) {
const orgTournaments = tournamentsWithOrg.filter(
(t) => t.organizationId === organizationId,
);
for (const series of orgSeries) {
const substringMatches = JSON.parse(series.substringMatches) as string[];
const matchingTournaments = orgTournaments
.filter((t) => matchesSubstring(t.name, substringMatches))
.filter((t) => tournamentTiers.has(t.tournamentId));
if (matchingTournaments.length === 0) {
seriesSkippedCount++;
continue;
}
const tierHistory = matchingTournaments
.slice(-TIER_HISTORY_LENGTH)
.map((t) => tournamentTiers.get(t.tournamentId)!);
console.log(
`Series ${series.id} (org ${organizationId}): ${matchingTournaments.length} matching tournaments, tierHistory = [${tierHistory.join(", ")}]`,
);
if (!dryRun) {
updateSeriesStatement.run({
seriesId: series.id,
tierHistory: JSON.stringify(tierHistory),
});
}
seriesUpdatedCount++;
}
}
console.log(`\nSeries updated: ${seriesUpdatedCount}`);
console.log(
`Series skipped (no matching tournaments): ${seriesSkippedCount}`,
);
if (dryRun) {
console.log("\nRun without --dry-run to apply changes");
}
}
main();

View File

@ -0,0 +1,406 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Tournament Tiering Experiment Script
*
* Run with: npx tsx scripts/tournament-tiers-experiment.ts
*
* Calculates tournament tiers based on top teams' average SeedingSkill.
* Tweak the THRESHOLDS object to experiment with different tier distributions.
*/
import Database from "better-sqlite3";
const db = new Database("db-prod.sqlite3", { readonly: true });
// ============================================================================
// CONFIGURATION - Tweak these values to experiment
// ============================================================================
/**
* Minimum ordinal thresholds for each tier.
* A tournament is assigned the highest tier where its score meets the threshold.
*
* Current values are based on percentile analysis:
* - X: Top ~1% of tournaments
* - S+: Top ~3%
* - S: Top ~8%
* - A+: Top ~15%
* - A: Top ~25%
* - B+: Top ~40%
* - B: Top ~55%
* - C+: Top ~75%
* - C: Everything else
*/
const THRESHOLDS = {
X: 32,
"S+": 29,
S: 26,
"A+": 24,
A: 21,
"B+": 15,
B: 10,
"C+": 5,
C: Number.NEGATIVE_INFINITY, // Catch-all
} as const;
/**
* How many top teams to consider for the tournament score.
* Using 8 captures the competitive core of most tournaments.
*/
const TOP_TEAMS_COUNT = 8;
/**
* Minimum number of teams required for a tournament to be tiered.
* Tournaments with fewer teams will be marked as "UNTIERED".
*/
const MIN_TEAMS_FOR_TIERING = 8;
/**
* Size bonus configuration.
* The bonus scales inversely with skill level - X-tier gets no bonus,
* lower tiers get increasingly more bonus for larger tournaments.
*
* NO_BONUS_ABOVE: Score threshold above which no size bonus applies (X-tier)
* MAX_BONUS_PER_10_TEAMS: Maximum bonus per 10 teams above minimum (applied at score 0)
*
* The bonus scales linearly: at NO_BONUS_ABOVE, multiplier is 0.
* As score decreases toward 0, multiplier approaches MAX_BONUS_PER_10_TEAMS.
*
* Formula: bonus = scaleFactor * MAX_BONUS * (teamsAboveMin / 10)
* where scaleFactor = max(0, (NO_BONUS_ABOVE - rawScore) / NO_BONUS_ABOVE)
*/
const SIZE_BONUS = {
NO_BONUS_ABOVE: 32, // X-tier threshold - no bonus at this level
MAX_BONUS_PER_10_TEAMS: 1.5, // Max points added per 10 extra teams (at score ~0)
};
/**
* Filter to only include tournaments after this date (ISO string).
* Set to null to include all tournaments.
*/
const MIN_DATE: string | null = null; // e.g., "2024-01-01"
// ============================================================================
// IMPLEMENTATION
// ============================================================================
type Tier = keyof typeof THRESHOLDS;
interface TournamentData {
tournamentId: number;
eventId: number;
name: string;
startTime: number;
teamCount: number;
top8AvgOrdinal: number | null;
adjustedScore: number | null;
tier: Tier | "UNTIERED";
}
function calculateTier(score: number | null): Tier | "UNTIERED" {
if (score === null) return "UNTIERED";
const tiers = Object.entries(THRESHOLDS) as [Tier, number][];
for (const [tier, threshold] of tiers) {
if (score >= threshold) return tier;
}
return "C";
}
function calculateAdjustedScore(rawScore: number, teamCount: number): number {
if (SIZE_BONUS.MAX_BONUS_PER_10_TEAMS === 0) return rawScore;
// Scale factor: 0 at NO_BONUS_ABOVE, approaches 1 as score approaches 0
const scaleFactor = Math.max(
0,
(SIZE_BONUS.NO_BONUS_ABOVE - rawScore) / SIZE_BONUS.NO_BONUS_ABOVE,
);
const teamsAboveMin = Math.max(0, teamCount - MIN_TEAMS_FOR_TIERING);
const bonus =
scaleFactor * SIZE_BONUS.MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10);
return rawScore + bonus;
}
function getTournamentData(): TournamentData[] {
const query = `
WITH TeamSkills AS (
SELECT
tt.tournamentId,
tt.id as teamId,
AVG(ss.ordinal) as avg_team_ordinal
FROM TournamentTeam tt
JOIN TournamentTeamMember ttm ON ttm.tournamentTeamId = tt.id
LEFT JOIN SeedingSkill ss ON ss.userId = ttm.userId AND ss.type = 'RANKED'
WHERE tt.droppedOut = 0
GROUP BY tt.tournamentId, tt.id
),
TeamCounts AS (
SELECT tournamentId, COUNT(*) as team_count
FROM TeamSkills
WHERE avg_team_ordinal IS NOT NULL
GROUP BY tournamentId
),
RankedTeams AS (
SELECT
ts.tournamentId,
ts.avg_team_ordinal,
tc.team_count,
ROW_NUMBER() OVER (PARTITION BY ts.tournamentId ORDER BY ts.avg_team_ordinal DESC) as rank
FROM TeamSkills ts
JOIN TeamCounts tc ON tc.tournamentId = ts.tournamentId
WHERE ts.avg_team_ordinal IS NOT NULL
),
TournamentScores AS (
SELECT
tournamentId,
AVG(avg_team_ordinal) as top8_avg_ordinal,
MAX(team_count) as team_count
FROM RankedTeams
WHERE rank <= ${TOP_TEAMS_COUNT}
GROUP BY tournamentId
)
SELECT
t.id as tournamentId,
ce.id as eventId,
ce.name,
ced.startTime,
ts.team_count as teamCount,
ts.top8_avg_ordinal as top8AvgOrdinal
FROM Tournament t
JOIN CalendarEvent ce ON ce.tournamentId = t.id
JOIN CalendarEventDate ced ON ced.eventId = ce.id
LEFT JOIN TournamentScores ts ON ts.tournamentId = t.id
WHERE t.isFinalized = 1
${MIN_DATE ? `AND ced.startTime >= strftime('%s', '${MIN_DATE}') * 1000` : ""}
GROUP BY t.id
ORDER BY ced.startTime DESC
`;
const rows = db.prepare(query).all() as Array<{
tournamentId: number;
eventId: number;
name: string;
startTime: number;
teamCount: number | null;
top8AvgOrdinal: number | null;
}>;
return rows.map((row) => {
const teamCount = row.teamCount ?? 0;
const meetsMinTeams = teamCount >= MIN_TEAMS_FOR_TIERING;
let adjustedScore: number | null = null;
if (row.top8AvgOrdinal !== null && meetsMinTeams) {
adjustedScore = calculateAdjustedScore(row.top8AvgOrdinal, teamCount);
}
return {
tournamentId: row.tournamentId,
eventId: row.eventId,
name: row.name,
startTime: row.startTime,
teamCount,
top8AvgOrdinal: row.top8AvgOrdinal,
adjustedScore,
tier: meetsMinTeams ? calculateTier(adjustedScore) : "UNTIERED",
};
});
}
function printDistribution(tournaments: TournamentData[]) {
const distribution: Record<string, number> = {};
const tiers = [...Object.keys(THRESHOLDS), "UNTIERED"];
for (const tier of tiers) {
distribution[tier] = 0;
}
for (const t of tournaments) {
distribution[t.tier]++;
}
const total = tournaments.length;
const tiered = total - distribution.UNTIERED;
console.log(`\n${"=".repeat(60)}`);
console.log("TIER DISTRIBUTION");
console.log("=".repeat(60));
console.log(`Total tournaments: ${total}`);
console.log(`Tiered (${MIN_TEAMS_FOR_TIERING}+ teams): ${tiered}`);
console.log(
`Untiered (< ${MIN_TEAMS_FOR_TIERING} teams): ${distribution.UNTIERED}`,
);
console.log("-".repeat(60));
for (const tier of tiers) {
if (tier === "UNTIERED") continue;
const count = distribution[tier];
const pctOfTiered = tiered > 0 ? ((count / tiered) * 100).toFixed(1) : "0";
const bar = "█".repeat(Math.round(count / 20));
console.log(
`${tier.padEnd(3)} | ${String(count).padStart(4)} | ${pctOfTiered.padStart(5)}% of tiered | ${bar}`,
);
}
}
function formatDate(timestamp: number): string {
// timestamps are stored in seconds, not milliseconds
return new Date(timestamp * 1000).toISOString().split("T")[0];
}
function printTopTournaments(
tournaments: TournamentData[],
tier: Tier,
limit = 10,
) {
const filtered = tournaments
.filter((t) => t.tier === tier)
.sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0))
.slice(0, limit);
console.log(`\n${"=".repeat(60)}`);
console.log(`TOP ${limit} ${tier}-TIER TOURNAMENTS`);
console.log("=".repeat(60));
for (const t of filtered) {
const date = formatDate(t.startTime);
console.log(
`[${date}] ${t.name.substring(0, 40).padEnd(40)} | ${t.teamCount} teams | score: ${t.adjustedScore?.toFixed(1)}`,
);
}
}
function printBottomOfTier(
tournaments: TournamentData[],
tier: Tier,
limit = 5,
) {
const filtered = tournaments
.filter((t) => t.tier === tier)
.sort((a, b) => (a.adjustedScore ?? 0) - (b.adjustedScore ?? 0))
.slice(0, limit);
console.log(`\n${"-".repeat(60)}`);
console.log(`BOTTOM ${limit} OF ${tier}-TIER (borderline)`);
console.log("-".repeat(60));
for (const t of filtered) {
const date = formatDate(t.startTime);
console.log(
`[${date}] ${t.name.substring(0, 40).padEnd(40)} | ${t.teamCount} teams | score: ${t.adjustedScore?.toFixed(1)}`,
);
}
}
function printThresholds() {
console.log(`\n${"=".repeat(60)}`);
console.log("CURRENT THRESHOLDS");
console.log("=".repeat(60));
for (const [tier, threshold] of Object.entries(THRESHOLDS)) {
if (threshold === Number.NEGATIVE_INFINITY) {
console.log(`${tier}: < ${THRESHOLDS["C+"]}`);
} else {
console.log(`${tier}: >= ${threshold}`);
}
}
console.log(`\nTop teams considered: ${TOP_TEAMS_COUNT}`);
console.log(`Min teams for tiering: ${MIN_TEAMS_FOR_TIERING}`);
console.log("\nSize bonus (scales inversely with skill):");
console.log(` No bonus above score: ${SIZE_BONUS.NO_BONUS_ABOVE}`);
console.log(
` Max bonus per 10 teams: ${SIZE_BONUS.MAX_BONUS_PER_10_TEAMS} points`,
);
// Show example bonus calculations
console.log("\nExample bonuses for 50-team tournament:");
const exampleTeams = 50;
for (const rawScore of [32, 28, 24, 20, 15, 10, 5, 0]) {
const adjusted = calculateAdjustedScore(rawScore, exampleTeams);
const bonus = adjusted - rawScore;
console.log(
` Raw ${rawScore.toString().padStart(2)} -> ${adjusted.toFixed(2)} (+${bonus.toFixed(2)})`,
);
}
}
// ============================================================================
// MAIN
// ============================================================================
function main() {
console.log("Tournament Tiering Experiment");
console.log("==============================\n");
printThresholds();
const tournaments = getTournamentData();
printDistribution(tournaments);
// Show examples from top tiers
printTopTournaments(tournaments, "X", 15);
printBottomOfTier(tournaments, "X", 5);
printTopTournaments(tournaments, "S+", 10);
printBottomOfTier(tournaments, "S+", 5);
printTopTournaments(tournaments, "S", 10);
// Show tournaments promoted by size bonus
console.log(`\n${"=".repeat(60)}`);
console.log("TOURNAMENTS PROMOTED BY SIZE BONUS");
console.log("=".repeat(60));
const promoted = tournaments
.filter((t) => t.tier !== "UNTIERED" && t.top8AvgOrdinal !== null)
.filter((t) => {
const rawTier = calculateTier(t.top8AvgOrdinal);
return rawTier !== t.tier;
})
.sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0))
.slice(0, 20);
if (promoted.length === 0) {
console.log("No tournaments were promoted by size bonus.");
} else {
for (const t of promoted) {
const rawTier = calculateTier(t.top8AvgOrdinal);
const bonus = (t.adjustedScore ?? 0) - (t.top8AvgOrdinal ?? 0);
console.log(
`${rawTier.toString().padEnd(3)} -> ${t.tier.padEnd(3)} | ${t.name.substring(0, 35).padEnd(35)} | ${t.teamCount} teams | +${bonus.toFixed(2)}`,
);
}
}
// Recent tournaments analysis
console.log(`\n${"=".repeat(60)}`);
console.log("RECENT TOURNAMENTS (last 30 tiered)");
console.log("=".repeat(60));
tournaments
.filter((t) => t.tier !== "UNTIERED")
.sort((a, b) => b.startTime - a.startTime)
.slice(0, 30)
.forEach((t) => {
const date = formatDate(t.startTime);
const safeName = t.name.substring(0, 35).padEnd(35);
console.log(
`${t.tier.padEnd(3)} | [${date}] ${safeName} | ${String(t.teamCount).padStart(3)} teams | ${t.adjustedScore?.toFixed(1)}`,
);
});
// Full CSV dump ordered by score
console.log(`\n${"=".repeat(60)}`);
console.log("FULL CSV DUMP (ordered by score descending)");
console.log("=".repeat(60));
console.log("name,score,tier");
tournaments
.filter((t) => t.tier !== "UNTIERED")
.sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0))
.forEach((t) => {
const safeName = t.name.replace(/,/g, ";").replace(/"/g, "'");
console.log(`"${safeName}",${t.adjustedScore?.toFixed(2)},${t.tier}`);
});
}
main();
db.close();

1710
tournament-tiers.csv Normal file

File diff suppressed because it is too large Load Diff