mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Tournament tiers (#2758)
This commit is contained in:
parent
dff8b39ac6
commit
60add3bfc6
55
app/components/TierPill.module.css
Normal file
55
app/components/TierPill.module.css
Normal 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%);
|
||||||
|
}
|
||||||
43
app/components/TierPill.tsx
Normal file
43
app/components/TierPill.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2748,9 +2748,23 @@ async function organization() {
|
||||||
roleDisplayName: null,
|
roleDisplayName: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: [],
|
series: [
|
||||||
|
{
|
||||||
|
name: "PICNIC",
|
||||||
|
description: "PICNIC tournament series",
|
||||||
|
showLeaderboard: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
badges: [],
|
badges: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sql
|
||||||
|
.prepare(
|
||||||
|
`UPDATE "TournamentOrganizationSeries"
|
||||||
|
SET "tierHistory" = '[3, 4, 3]'
|
||||||
|
WHERE "organizationId" = 1 AND "name" = 'PICNIC'`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function liveStreams() {
|
function liveStreams() {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||||
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
|
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
|
||||||
import type { ScrimFilters } from "~/features/scrims/scrims-types";
|
import type { ScrimFilters } from "~/features/scrims/scrims-types";
|
||||||
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
|
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 PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||||
import type { ParticipantResult } from "~/modules/brackets-model";
|
import type { ParticipantResult } from "~/modules/brackets-model";
|
||||||
|
|
@ -497,6 +498,8 @@ export interface Tournament {
|
||||||
isFinalized: Generated<DBBoolean>;
|
isFinalized: Generated<DBBoolean>;
|
||||||
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
|
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
|
||||||
seedingSnapshot: JSONColumnTypeNullable<SeedingSnapshot>;
|
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 {
|
export interface SeedingSnapshot {
|
||||||
|
|
@ -766,6 +769,7 @@ export interface TournamentOrganizationSeries {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
substringMatches: JSONColumnType<string[]>;
|
substringMatches: JSONColumnType<string[]>;
|
||||||
showLeaderboard: Generated<number>;
|
showLeaderboard: Generated<number>;
|
||||||
|
tierHistory: JSONColumnTypeNullable<TournamentTierNumber[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentBracketProgressionOverride {
|
export interface TournamentBracketProgressionOverride {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { SEED_VARIATIONS } from "~/features/api-private/constants";
|
||||||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||||
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
|
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
|
||||||
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.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 { cache } from "~/utils/cache.server";
|
||||||
import { parseRequestPayload } from "~/utils/remix.server";
|
import { parseRequestPayload } from "~/utils/remix.server";
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
await refreshBannedCache();
|
await refreshBannedCache();
|
||||||
await refreshSendouQInstance();
|
await refreshSendouQInstance();
|
||||||
|
await refreshTentativeTiersCache();
|
||||||
|
|
||||||
return Response.json(null);
|
return Response.json(null);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sub } from "date-fns";
|
||||||
import type {
|
import type {
|
||||||
Expression,
|
Expression,
|
||||||
ExpressionBuilder,
|
ExpressionBuilder,
|
||||||
|
|
@ -17,6 +18,7 @@ import type {
|
||||||
} from "~/db/tables";
|
} from "~/db/tables";
|
||||||
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
|
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
|
||||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||||
|
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
|
||||||
import {
|
import {
|
||||||
databaseTimestampNow,
|
databaseTimestampNow,
|
||||||
databaseTimestampToDate,
|
databaseTimestampToDate,
|
||||||
|
|
@ -168,9 +170,11 @@ function findAllBetweenTwoTimestampsQuery({
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
"CalendarEvent.id as eventId",
|
"CalendarEvent.id as eventId",
|
||||||
"CalendarEvent.authorId",
|
"CalendarEvent.authorId",
|
||||||
|
"CalendarEvent.organizationId",
|
||||||
"Tournament.id as tournamentId",
|
"Tournament.id as tournamentId",
|
||||||
"Tournament.settings as tournamentSettings",
|
"Tournament.settings as tournamentSettings",
|
||||||
"Tournament.mapPickingStyle",
|
"Tournament.mapPickingStyle",
|
||||||
|
"Tournament.tier",
|
||||||
"CalendarEvent.name",
|
"CalendarEvent.name",
|
||||||
"CalendarEvent.tags",
|
"CalendarEvent.tags",
|
||||||
"CalendarEventDate.startTime",
|
"CalendarEventDate.startTime",
|
||||||
|
|
@ -227,6 +231,16 @@ function findAllBetweenTwoTimestampsMapped(
|
||||||
? (row.tags.split(",") as CalendarEvent["tags"])
|
? (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 {
|
return {
|
||||||
at: databaseTimestampToJavascriptTimestamp(row.startTime),
|
at: databaseTimestampToJavascriptTimestamp(row.startTime),
|
||||||
type: "calendar",
|
type: "calendar",
|
||||||
|
|
@ -261,6 +275,8 @@ function findAllBetweenTwoTimestampsMapped(
|
||||||
isTest: row.tournamentSettings.isTest ?? false,
|
isTest: row.tournamentSettings.isTest ?? false,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
tier: row.tier ?? null,
|
||||||
|
tentativeTier,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ interface CommonEvent {
|
||||||
url: string;
|
url: string;
|
||||||
/** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */
|
/** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */
|
||||||
isRanked: boolean | null;
|
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;
|
modes: Array<ModeShortWithSpecial> | null;
|
||||||
organization: {
|
organization: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,17 @@
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--semi-bold);
|
||||||
color: var(--text-lighter);
|
color: var(--text-lighter);
|
||||||
display: block;
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
text-align: center;
|
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--semi-bold);
|
||||||
font-size: var(--fonts-sm);
|
font-size: var(--fonts-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -69,6 +76,10 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nameWithTier {
|
||||||
|
max-width: 165px;
|
||||||
|
}
|
||||||
|
|
||||||
.teamCount {
|
.teamCount {
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--fonts-xxs);
|
||||||
font-weight: var(--bold);
|
font-weight: var(--bold);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Flag } from "~/components/Flag";
|
||||||
import { Image, ModeImage } from "~/components/Image";
|
import { Image, ModeImage } from "~/components/Image";
|
||||||
import { TrophyIcon } from "~/components/icons/Trophy";
|
import { TrophyIcon } from "~/components/icons/Trophy";
|
||||||
import { UsersIcon } from "~/components/icons/Users";
|
import { UsersIcon } from "~/components/icons/Users";
|
||||||
|
import { TierPill } from "~/components/TierPill";
|
||||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||||
|
|
@ -79,25 +80,37 @@ export function TournamentCard({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.name, {
|
className={clsx(styles.nameRow, {
|
||||||
"mt-3": !isHostedOnSendouInk,
|
"mt-3": !isHostedOnSendouInk,
|
||||||
"mt-1": isHostedOnSendouInk,
|
"mt-1": isHostedOnSendouInk,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{tournament.name}{" "}
|
<div
|
||||||
{isShowcase ? (
|
className={clsx(styles.name, {
|
||||||
<time
|
[styles.nameWithTier]:
|
||||||
className={clsx(styles.time, {
|
tournament.tier || tournament.tentativeTier,
|
||||||
invisible: !isMounted,
|
})}
|
||||||
})}
|
>
|
||||||
dateTime={databaseTimestampToDate(
|
{tournament.name}
|
||||||
tournament.startTime,
|
</div>
|
||||||
).toISOString()}
|
{tournament.tier ? (
|
||||||
>
|
<TierPill tier={tournament.tier} />
|
||||||
{time()}
|
) : tournament.tentativeTier ? (
|
||||||
</time>
|
<TierPill tier={tournament.tentativeTier} isTentative />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{isShowcase ? (
|
||||||
|
<time
|
||||||
|
className={clsx(styles.time, {
|
||||||
|
invisible: !isMounted,
|
||||||
|
})}
|
||||||
|
dateTime={databaseTimestampToDate(
|
||||||
|
tournament.startTime,
|
||||||
|
).toISOString()}
|
||||||
|
>
|
||||||
|
{time()}
|
||||||
|
</time>
|
||||||
|
) : null}
|
||||||
{isCalendar ? (
|
{isCalendar ? (
|
||||||
<div className="stack sm items-center my-2">
|
<div className="stack sm items-center my-2">
|
||||||
<Tags tags={tournament.tags} small centered />
|
<Tags tags={tournament.tags} small centered />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ function makeEvent(
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
id: 1,
|
id: 1,
|
||||||
isRanked: null,
|
isRanked: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
modes: ["SZ"],
|
modes: ["SZ"],
|
||||||
teamsCount: 2,
|
teamsCount: 2,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getBracketProgressionLabel,
|
getBracketProgressionLabel,
|
||||||
tournamentIsRanked,
|
tournamentIsRanked,
|
||||||
} from "~/features/tournament/tournament-utils";
|
} from "~/features/tournament/tournament-utils";
|
||||||
|
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
|
||||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||||
import {
|
import {
|
||||||
databaseTimestampToDate,
|
databaseTimestampToDate,
|
||||||
|
|
@ -169,7 +170,6 @@ async function cachedTournaments() {
|
||||||
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||||
async getFreshValue() {
|
async getFreshValue() {
|
||||||
const tournaments = await TournamentRepository.forShowcase();
|
const tournaments = await TournamentRepository.forShowcase();
|
||||||
|
|
||||||
const mapped = tournaments.map(mapTournamentFromDB);
|
const mapped = tournaments.map(mapTournamentFromDB);
|
||||||
|
|
||||||
return deleteExtraResults(mapped);
|
return deleteExtraResults(mapped);
|
||||||
|
|
@ -276,6 +276,13 @@ function mapTournamentFromDB(
|
||||||
): ShowcaseCalendarEvent {
|
): ShowcaseCalendarEvent {
|
||||||
const highestDivWinners = resolveHighestDivisionWinners(tournament);
|
const highestDivWinners = resolveHighestDivisionWinners(tournament);
|
||||||
|
|
||||||
|
const tentativeTier =
|
||||||
|
tournament.tier === null &&
|
||||||
|
tournament.organizationId !== null &&
|
||||||
|
!tournament.firstPlacers.length
|
||||||
|
? getTentativeTier(tournament.organizationId, tournament.name)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "showcase",
|
type: "showcase",
|
||||||
url: tournamentPage(tournament.id),
|
url: tournamentPage(tournament.id),
|
||||||
|
|
@ -297,8 +304,10 @@ function mapTournamentFromDB(
|
||||||
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
||||||
isTest: tournament.settings.isTest ?? false,
|
isTest: tournament.settings.isTest ?? false,
|
||||||
}),
|
}),
|
||||||
|
tier: tournament.tier ?? null,
|
||||||
|
tentativeTier,
|
||||||
hidden: Boolean(tournament.hidden),
|
hidden: Boolean(tournament.hidden),
|
||||||
modes: null, // no need to show modes for front page, maybe could in the future?
|
modes: null,
|
||||||
firstPlacer:
|
firstPlacer:
|
||||||
highestDivWinners.length > 0
|
highestDivWinners.length > 0
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import {
|
||||||
type TournamentBadgeReceivers,
|
type TournamentBadgeReceivers,
|
||||||
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
} from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
||||||
import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils";
|
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 invariant from "~/utils/invariant";
|
||||||
import { logger } from "~/utils/logger";
|
import { logger } from "~/utils/logger";
|
||||||
import {
|
import {
|
||||||
|
|
@ -106,6 +108,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||||
finalizeTournament(tournamentId);
|
finalizeTournament(tournamentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateSeriesTierHistory(tournament);
|
||||||
|
|
||||||
if (tournament.ranked) {
|
if (tournament.ranked) {
|
||||||
try {
|
try {
|
||||||
refreshUserSkills(season!);
|
refreshUserSkills(season!);
|
||||||
|
|
@ -177,3 +181,25 @@ async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) {
|
||||||
logger.error("Error notifying badge receivers", error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { sql } from "~/db/sql";
|
||||||
import { requireUser } from "~/features/auth/core/user.server";
|
import { requireUser } from "~/features/auth/core/user.server";
|
||||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||||
import { notify } from "~/features/notifications/core/notify.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 { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
||||||
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
||||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.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) {
|
if (!tournament.isTest) {
|
||||||
notify({
|
notify({
|
||||||
userIds: seeding.flatMap((tournamentTeamId) =>
|
userIds: seeding.flatMap((tournamentTeamId) =>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
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 type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
import { isAdmin } from "~/modules/permissions/utils";
|
import { isAdmin } from "~/modules/permissions/utils";
|
||||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||||
|
|
@ -54,10 +55,16 @@ function dataMapped({
|
||||||
isAdmin(user);
|
isAdmin(user);
|
||||||
const revealInfo = tournamentHasStarted || isOrganizer;
|
const revealInfo = tournamentHasStarted || isOrganizer;
|
||||||
|
|
||||||
|
const tentativeTier =
|
||||||
|
!ctx.tier && ctx.organization?.id
|
||||||
|
? getTentativeTier(ctx.organization.id, ctx.name)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
|
tentativeTier,
|
||||||
teams: ctx.teams.map((team) => {
|
teams: ctx.teams.map((team) => {
|
||||||
const isOwnTeam = team.members.some(
|
const isOwnTeam = team.members.some(
|
||||||
(member) => member.userId === user?.id,
|
(member) => member.userId === user?.id,
|
||||||
|
|
|
||||||
|
|
@ -6834,6 +6834,8 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||||
id: 815,
|
id: 815,
|
||||||
eventId: 2614,
|
eventId: 2614,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
discordUrl: "https://discord.gg/F7RaNUR",
|
discordUrl: "https://discord.gg/F7RaNUR",
|
||||||
tags: "LOW",
|
tags: "LOW",
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
||||||
|
|
@ -1934,6 +1934,8 @@ export const SWIM_OR_SINK_167 = (
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 672,
|
id: 672,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
eventId: 2425,
|
eventId: 2425,
|
||||||
discordUrl: "https://discord.gg/F7RaNUR",
|
discordUrl: "https://discord.gg/F7RaNUR",
|
||||||
tags: null,
|
tags: null,
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 891,
|
id: 891,
|
||||||
eventId: 2698,
|
eventId: 2698,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
discordUrl: "https://discord.gg/A6NW3VCHRh",
|
discordUrl: "https://discord.gg/A6NW3VCHRh",
|
||||||
tags: "REGION,SZ",
|
tags: "REGION,SZ",
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
||||||
|
|
@ -1396,6 +1396,8 @@ export const PADDLING_POOL_257 = () =>
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 27,
|
id: 27,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1352,
|
eventId: 1352,
|
||||||
|
|
@ -8001,6 +8003,8 @@ export const PADDLING_POOL_255 = () =>
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 18,
|
id: 18,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1286,
|
eventId: 1286,
|
||||||
|
|
@ -14946,6 +14950,8 @@ export const IN_THE_ZONE_32 = ({
|
||||||
id: 11,
|
id: 11,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1134,
|
eventId: 1134,
|
||||||
bracketProgressionOverrides: [],
|
bracketProgressionOverrides: [],
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ export const testTournament = ({
|
||||||
tags: null,
|
tags: null,
|
||||||
description: null,
|
description: null,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
tier: null,
|
||||||
|
tentativeTier: null,
|
||||||
parentTournamentId: null,
|
parentTournamentId: null,
|
||||||
rules: null,
|
rules: null,
|
||||||
logoUrl: "/test.png",
|
logoUrl: "/test.png",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { sql } from "kysely";
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||||
import { db } from "~/db/sql";
|
import { db } from "~/db/sql";
|
||||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||||
|
import {
|
||||||
|
type TournamentTierNumber,
|
||||||
|
updateTierHistory,
|
||||||
|
} from "~/features/tournament/core/tiering";
|
||||||
import {
|
import {
|
||||||
databaseTimestampNow,
|
databaseTimestampNow,
|
||||||
databaseTimestampToDate,
|
databaseTimestampToDate,
|
||||||
|
|
@ -92,6 +96,7 @@ export async function findBySlug(slug: string) {
|
||||||
"TournamentOrganizationSeries.substringMatches",
|
"TournamentOrganizationSeries.substringMatches",
|
||||||
"TournamentOrganizationSeries.showLeaderboard",
|
"TournamentOrganizationSeries.showLeaderboard",
|
||||||
"TournamentOrganizationSeries.description",
|
"TournamentOrganizationSeries.description",
|
||||||
|
"TournamentOrganizationSeries.tierHistory",
|
||||||
])
|
])
|
||||||
.whereRef(
|
.whereRef(
|
||||||
"TournamentOrganizationSeries.organizationId",
|
"TournamentOrganizationSeries.organizationId",
|
||||||
|
|
@ -553,3 +558,43 @@ export function updateIsEstablished(
|
||||||
.where("id", "=", organizationId)
|
.where("id", "=", organizationId)
|
||||||
.execute();
|
.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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { LoaderFunctionArgs } from "react-router";
|
import type { LoaderFunctionArgs } from "react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getUser } from "~/features/auth/core/user.server";
|
import { getUser } from "~/features/auth/core/user.server";
|
||||||
|
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
|
||||||
import type { SerializeFrom } from "~/utils/remix";
|
import type { SerializeFrom } from "~/utils/remix";
|
||||||
import { parseSafeSearchParams } from "~/utils/remix.server";
|
import { parseSafeSearchParams } from "~/utils/remix.server";
|
||||||
import { id } from "~/utils/zod";
|
import { id } from "~/utils/zod";
|
||||||
|
|
@ -52,6 +53,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||||
description: series.description,
|
description: series.description,
|
||||||
page,
|
page,
|
||||||
leaderboard: series.showLeaderboard ? leaderboard : null,
|
leaderboard: series.showLeaderboard ? leaderboard : null,
|
||||||
|
tentativeTier: series.tierHistory
|
||||||
|
? calculateTentativeTier(series.tierHistory)
|
||||||
|
: null,
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { UsersIcon } from "~/components/icons/Users";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
import { Pagination } from "~/components/Pagination";
|
import { Pagination } from "~/components/Pagination";
|
||||||
import { Placement } from "~/components/Placement";
|
import { Placement } from "~/components/Placement";
|
||||||
|
import { TierPill } from "~/components/TierPill";
|
||||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||||
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
|
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
|
||||||
import { SendouForm } from "~/form/SendouForm";
|
import { SendouForm } from "~/form/SendouForm";
|
||||||
|
|
@ -329,7 +330,12 @@ function SeriesHeader({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<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 ? (
|
{series.established ? (
|
||||||
<div className="text-lighter text-italic text-xs">
|
<div className="text-lighter text-italic text-xs">
|
||||||
{t("org:events.established.short")}{" "}
|
{t("org:events.established.short")}{" "}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
userChatNameColor,
|
userChatNameColor,
|
||||||
} from "~/utils/kysely.server";
|
} from "~/utils/kysely.server";
|
||||||
import type { Unwrapped } from "~/utils/types";
|
import type { Unwrapped } from "~/utils/types";
|
||||||
|
import type { TournamentTierNumber } from "./core/tiering";
|
||||||
|
|
||||||
export type FindById = NonNullable<Unwrapped<typeof findById>>;
|
export type FindById = NonNullable<Unwrapped<typeof findById>>;
|
||||||
export async function findById(id: number) {
|
export async function findById(id: number) {
|
||||||
|
|
@ -51,6 +52,7 @@ export async function findById(id: number) {
|
||||||
"Tournament.mapPickingStyle",
|
"Tournament.mapPickingStyle",
|
||||||
"Tournament.rules",
|
"Tournament.rules",
|
||||||
"Tournament.parentTournamentId",
|
"Tournament.parentTournamentId",
|
||||||
|
"Tournament.tier",
|
||||||
"CalendarEvent.name",
|
"CalendarEvent.name",
|
||||||
"CalendarEvent.description",
|
"CalendarEvent.description",
|
||||||
"CalendarEventDate.startTime",
|
"CalendarEventDate.startTime",
|
||||||
|
|
@ -464,8 +466,10 @@ export function forShowcase() {
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
"Tournament.id",
|
"Tournament.id",
|
||||||
"Tournament.settings",
|
"Tournament.settings",
|
||||||
|
"Tournament.tier",
|
||||||
"CalendarEvent.authorId",
|
"CalendarEvent.authorId",
|
||||||
"CalendarEvent.name",
|
"CalendarEvent.name",
|
||||||
|
"CalendarEvent.organizationId",
|
||||||
"CalendarEventDate.startTime",
|
"CalendarEventDate.startTime",
|
||||||
"CalendarEvent.hidden",
|
"CalendarEvent.hidden",
|
||||||
eb
|
eb
|
||||||
|
|
@ -1234,3 +1238,17 @@ export function updateTeamSeeds({
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateTournamentTier({
|
||||||
|
tournamentId,
|
||||||
|
tier,
|
||||||
|
}: {
|
||||||
|
tournamentId: number;
|
||||||
|
tier: TournamentTierNumber;
|
||||||
|
}) {
|
||||||
|
return db
|
||||||
|
.updateTable("Tournament")
|
||||||
|
.set({ tier })
|
||||||
|
.where("id", "=", tournamentId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
|
||||||
240
app/features/tournament/core/tiering.test.ts
Normal file
240
app/features/tournament/core/tiering.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
148
app/features/tournament/core/tiering.ts
Normal file
148
app/features/tournament/core/tiering.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ import { MapPoolStages } from "~/components/MapPoolSelector";
|
||||||
import { Markdown } from "~/components/Markdown";
|
import { Markdown } from "~/components/Markdown";
|
||||||
import { Section } from "~/components/Section";
|
import { Section } from "~/components/Section";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
|
import { TierPill } from "~/components/TierPill";
|
||||||
import TimePopover from "~/components/TimePopover";
|
import TimePopover from "~/components/TimePopover";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
|
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
|
||||||
|
|
@ -139,6 +140,11 @@ export default function TournamentRegisterPage() {
|
||||||
Unranked
|
Unranked
|
||||||
</div>
|
</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">
|
<div className="tournament__badge tournament__badge__modes">
|
||||||
{tournament.modesIncluded.map((mode) => (
|
{tournament.modesIncluded.map((mode) => (
|
||||||
<ModeImage key={mode} mode={mode} size={16} />
|
<ModeImage key={mode} mode={mode} size={16} />
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,7 @@ const baseTournamentResultsQuery = (userId: number) =>
|
||||||
"CalendarEvent.tournamentId",
|
"CalendarEvent.tournamentId",
|
||||||
"TournamentResult.tournamentId",
|
"TournamentResult.tournamentId",
|
||||||
)
|
)
|
||||||
|
.innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId")
|
||||||
.where("TournamentResult.userId", "=", userId);
|
.where("TournamentResult.userId", "=", userId);
|
||||||
|
|
||||||
export function findResultsByUserId(
|
export function findResultsByUserId(
|
||||||
|
|
@ -537,6 +538,7 @@ export function findResultsByUserId(
|
||||||
sql`1`,
|
sql`1`,
|
||||||
sql`0`,
|
sql`0`,
|
||||||
]).as("isHighlight"),
|
]).as("isHighlight"),
|
||||||
|
sql<number | null>`null`.as("tier"),
|
||||||
withMaxEventStartTime(eb),
|
withMaxEventStartTime(eb),
|
||||||
jsonArrayFrom(
|
jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
|
|
@ -571,6 +573,7 @@ export function findResultsByUserId(
|
||||||
"TournamentTeam.id as teamId",
|
"TournamentTeam.id as teamId",
|
||||||
"TournamentTeam.name as teamName",
|
"TournamentTeam.name as teamName",
|
||||||
"TournamentResult.isHighlight",
|
"TournamentResult.isHighlight",
|
||||||
|
"Tournament.tier",
|
||||||
withMaxEventStartTime(eb),
|
withMaxEventStartTime(eb),
|
||||||
jsonArrayFrom(
|
jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { SendouPopover } from "~/components/elements/Popover";
|
||||||
import { UsersIcon } from "~/components/icons/Users";
|
import { UsersIcon } from "~/components/icons/Users";
|
||||||
import { Placement } from "~/components/Placement";
|
import { Placement } from "~/components/Placement";
|
||||||
import { Table } from "~/components/Table";
|
import { Table } from "~/components/Table";
|
||||||
|
import { TierPill } from "~/components/TierPill";
|
||||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||||
import { databaseTimestampToDate } from "~/utils/dates";
|
import { databaseTimestampToDate } from "~/utils/dates";
|
||||||
import {
|
import {
|
||||||
|
|
@ -117,6 +118,7 @@ export function UserResultsTable({
|
||||||
>
|
>
|
||||||
{result.eventName}
|
{result.eventName}
|
||||||
</Link>
|
</Link>
|
||||||
|
{result.tier ? <TierPill tier={result.tier} /> : null}
|
||||||
{result.div ? (
|
{result.div ? (
|
||||||
<span className="text-lighter">({result.div})</span>
|
<span className="text-lighter">({result.div})</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
37
e2e/tournament-tiers.spec.ts
Normal file
37
e2e/tournament-tiers.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -328,5 +328,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,5 +328,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,5 +328,7 @@
|
||||||
"api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.",
|
"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.button": "Regenerate token",
|
||||||
"api.regenerate.confirm": "Confirm Regenerate",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -324,5 +324,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -324,5 +324,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,5 +328,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,5 +331,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,5 +330,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,5 +331,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -324,5 +324,7 @@
|
||||||
"api.regenerate.heading": "",
|
"api.regenerate.heading": "",
|
||||||
"api.regenerate.button": "",
|
"api.regenerate.button": "",
|
||||||
"api.regenerate.confirm": "",
|
"api.regenerate.confirm": "",
|
||||||
"api.generate": ""
|
"api.generate": "",
|
||||||
|
"tier.tentative": "",
|
||||||
|
"tier.confirmed": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
migrations/116-tournament-tier.js
Normal file
6
migrations/116-tournament-tier.js
Normal 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();
|
||||||
|
}
|
||||||
257
scripts/backfill-tournament-tiers.ts
Normal file
257
scripts/backfill-tournament-tiers.ts
Normal 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();
|
||||||
406
scripts/tournament-tiers-experiment.ts
Normal file
406
scripts/tournament-tiers-experiment.ts
Normal 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
1710
tournament-tiers.csv
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user