League support (#2030)
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

* Initial

* Create league divs script works

* Progress

* Progress

* Prevent round from starting

* Finalized?

* Tweaks

* linter
This commit is contained in:
Kalle 2025-01-13 22:57:08 +02:00 committed by GitHub
parent 9a671457a2
commit 86b50ced56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1022 additions and 185 deletions

View File

@ -30,3 +30,5 @@ VITE_SKALOP_WS_URL=ws://localhost:5900
// trunc, full or none (default: none)
SQL_LOG=trunc
VITE_SHOW_LUTI_NAV_ITEM=false

View File

@ -1,6 +1,6 @@
import { Link } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import navItems from "~/components/layout/nav-items.json";
import { navItems } from "~/components/layout/nav-items";
import { useUser } from "~/features/auth/core/user";
import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls";
import { Avatar } from "../Avatar";

View File

@ -1,97 +0,0 @@
[
{
"name": "settings",
"url": "settings",
"prefetch": true
},
{
"name": "sendouq",
"url": "q",
"prefetch": false
},
{
"name": "analyzer",
"url": "analyzer",
"prefetch": true
},
{
"name": "builds",
"url": "builds",
"prefetch": true
},
{
"name": "object-damage-calculator",
"url": "object-damage-calculator",
"prefetch": true
},
{
"name": "leaderboards",
"url": "leaderboards",
"prefetch": false
},
{
"name": "lfg",
"url": "lfg",
"prefetch": false
},
{
"name": "plans",
"url": "plans",
"prefetch": false
},
{
"name": "badges",
"url": "badges",
"prefetch": false
},
{
"name": "calendar",
"url": "calendar",
"prefetch": false
},
{
"name": "plus",
"url": "plus/suggestions",
"prefetch": false
},
{
"name": "u",
"url": "u",
"prefetch": false
},
{
"name": "xsearch",
"url": "xsearch",
"prefetch": false
},
{
"name": "articles",
"url": "a",
"prefetch": false
},
{
"name": "vods",
"url": "vods",
"prefetch": false
},
{
"name": "art",
"url": "art",
"prefetch": false
},
{
"name": "t",
"url": "t",
"prefetch": false
},
{
"name": "links",
"url": "links",
"prefetch": true
},
{
"name": "maps",
"url": "maps",
"prefetch": false
}
]

View File

@ -0,0 +1,104 @@
export const navItems = [
{
name: "settings",
url: "settings",
prefetch: true,
},
import.meta.env.VITE_SHOW_LUTI_NAV_ITEM === "true"
? {
name: "luti",
url: "luti",
prefetch: false,
}
: null,
{
name: "sendouq",
url: "q",
prefetch: false,
},
{
name: "analyzer",
url: "analyzer",
prefetch: true,
},
{
name: "builds",
url: "builds",
prefetch: true,
},
{
name: "object-damage-calculator",
url: "object-damage-calculator",
prefetch: true,
},
{
name: "leaderboards",
url: "leaderboards",
prefetch: false,
},
{
name: "lfg",
url: "lfg",
prefetch: false,
},
{
name: "plans",
url: "plans",
prefetch: false,
},
{
name: "badges",
url: "badges",
prefetch: false,
},
{
name: "calendar",
url: "calendar",
prefetch: false,
},
{
name: "plus",
url: "plus/suggestions",
prefetch: false,
},
{
name: "u",
url: "u",
prefetch: false,
},
{
name: "xsearch",
url: "xsearch",
prefetch: false,
},
{
name: "articles",
url: "a",
prefetch: false,
},
{
name: "vods",
url: "vods",
prefetch: false,
},
{
name: "art",
url: "art",
prefetch: false,
},
{
name: "t",
url: "t",
prefetch: false,
},
{
name: "links",
url: "links",
prefetch: true,
},
{
name: "maps",
url: "maps",
prefetch: false,
},
].filter((item) => item !== null);

View File

@ -98,6 +98,10 @@ const calendarEventWithToToolsDepths = () => calendarEventWithToTools("DEPTHS");
const calendarEventWithToToolsTeamsDepths = () =>
calendarEventWithToToolsTeams("DEPTHS");
const calendarEventWithToToolsLUTI = () => calendarEventWithToTools("LUTI");
const calendarEventWithToToolsTeamsLUTI = () =>
calendarEventWithToToolsTeams("LUTI");
const basicSeeds = (variation?: SeedVariation | null) => [
adminUser,
makeAdminPatron,
@ -145,6 +149,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
calendarEventWithToToolsToSetMapPool,
calendarEventWithToToolsDepths,
calendarEventWithToToolsTeamsDepths,
calendarEventWithToToolsLUTI,
calendarEventWithToToolsTeamsLUTI,
tournamentSubs,
adminBuilds,
manySplattershotBuilds,
@ -218,6 +224,12 @@ function wipeDB() {
];
for (const table of tablesToDelete) {
if (table === "Tournament") {
// foreign key constraint reasons
sql
.prepare("delete from Tournament where parentTournamentId is not null")
.run();
}
sql.prepare(`delete from "${table}"`).run();
}
}
@ -861,7 +873,7 @@ async function calendarEventResults() {
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
function calendarEventWithToTools(
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
registrationOpen = false,
) {
const tournamentId = {
@ -870,6 +882,7 @@ function calendarEventWithToTools(
PP: 3,
SOS: 4,
DEPTHS: 5,
LUTI: 6,
}[event];
const eventId = {
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
@ -877,6 +890,7 @@ function calendarEventWithToTools(
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3,
DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4,
LUTI: TO_TOOLS_CALENDAR_EVENT_ID + 5,
}[event];
const name = {
PICNIC: "PICNIC #2",
@ -884,6 +898,7 @@ function calendarEventWithToTools(
PP: "Paddling Pool 253",
SOS: "Swim or Sink 101",
DEPTHS: "The Depths 5",
LUTI: "Leagues Under The Ink Season 15",
}[event];
const settings: Tables["Tournament"]["settings"] =
@ -987,16 +1002,34 @@ function calendarEventWithToTools(
},
],
}
: {
bracketProgression: [
{
type: "double_elimination",
name: "Main bracket",
requiresCheckIn: false,
settings: {},
},
],
};
: event === "LUTI"
? {
bracketProgression: [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Play-offs",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
],
}
: {
bracketProgression: [
{
type: "double_elimination",
name: "Main bracket",
requiresCheckIn: false,
settings: {},
},
],
};
sql
.prepare(
@ -1016,7 +1049,11 @@ function calendarEventWithToTools(
id: tournamentId,
settings: JSON.stringify(settings),
mapPickingStyle:
event === "SOS" ? "TO" : event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL",
event === "SOS" || event === "LUTI"
? "TO"
: event === "ITZ"
? "AUTO_SZ"
: "AUTO_ALL",
});
sql
@ -1156,7 +1193,7 @@ const availablePairs = rankedModesShort
)
.filter((pair) => !tiebreakerPicks.has(pair));
function calendarEventWithToToolsTeams(
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
isSmall = false,
) {
const userIds = userIdsInAscendingOrderById();
@ -1170,6 +1207,7 @@ function calendarEventWithToToolsTeams(
PP: 3,
SOS: 4,
DEPTHS: 5,
LUTI: 6,
}[event];
const teamIdAddition = {
@ -1178,6 +1216,7 @@ function calendarEventWithToToolsTeams(
PP: 200,
SOS: 300,
DEPTHS: 400,
LUTI: 500,
}[event];
for (let id = 1; id <= (isSmall ? 4 : 16); id++) {
@ -1212,8 +1251,8 @@ function calendarEventWithToToolsTeams(
inviteCode: nanoid(INVITE_CODE_LENGTH),
});
// in PICNIC & PP Chimera is not checked in
if (teamId !== 1 && teamId !== 201) {
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
if (teamId !== 1 && teamId !== 201 && event !== "LUTI") {
sql
.prepare(
`
@ -1268,7 +1307,11 @@ function calendarEventWithToToolsTeams(
});
}
if (event !== "SOS" && (Math.random() < 0.8 || id === 1)) {
if (
event !== "SOS" &&
event !== "LUTI" &&
(Math.random() < 0.8 || id === 1)
) {
const shuffledPairs = shuffle(availablePairs.slice());
let SZ = 0;

View File

@ -31,7 +31,7 @@ export const db = new Kysely<DB>({
dialect: new SqliteDialect({
database: sql,
}),
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : undefined,
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : logError,
plugins: [new ParseJSONResultsPlugin()],
});
@ -54,6 +54,23 @@ function logQuery(event: LogEvent) {
);
// biome-ignore lint/suspicious/noConsoleLog: dev only
console.log(formatSql(event.query.sql, event.query.parameters));
} else {
logError(event);
}
}
function logError(event: LogEvent) {
if (
event.level === "error" &&
// it seems that this error happens everytime something goes wrong inside transaction
// my guess is that the transaction is already implicitly rolled back in the case of error
// but kysely also does it explicitly -> fails because there is no transaction to rollback.
// this `logError` function at least makes it so that due to that the error doesn't get logged
// but of course the best solution would also avoid useless rollbacks, something for the future
// btw this particular check is here just to avoid the double "no transaction is active" log
!(event.error as any).message.includes("no transaction is active")
) {
console.error(event.error);
}
}

View File

@ -129,6 +129,7 @@ export interface CalendarEvent {
name: string;
participantCount: number | null;
tags: string | null;
hidden: Generated<number>;
tournamentId: number | null;
organizationId: number | null;
avatarImgId: number | null;
@ -455,6 +456,8 @@ export interface Tournament {
string | null
>;
rules: string | null;
/** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */
parentTournamentId: number | null;
}
export interface PreparedMaps {

View File

@ -15,7 +15,6 @@ import { UserSearch } from "~/components/UserSearch";
import { useUser } from "~/features/auth/core/user";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { isAdmin, isMod } from "~/permissions";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
@ -27,10 +26,6 @@ export const meta: MetaFunction = () => {
return [{ title: makeTitle("Admin page") }];
};
export const handle: SendouRouteHandle = {
navItemName: "admin",
};
export default function AdminPage() {
const user = useUser();

View File

@ -215,6 +215,7 @@ export async function findAllBetweenTwoTimestamps({
"<=",
dateToDatabaseTimestamp(endTime),
)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startTime", "asc");
for (const tag of tagsToFilterBy) {
@ -362,6 +363,7 @@ export async function eventsToReport(authorId: number) {
fn.max("CalendarEventDate.startTime").as("startTime"),
])
.where("CalendarEvent.authorId", "=", authorId)
.where("CalendarEvent.hidden", "=", 0)
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
.where("CalendarEvent.participantCount", "is", null)
@ -382,6 +384,7 @@ export async function findRecentMapPoolsByAuthorId(authorId: number) {
withMapPool(eb),
])
.where("CalendarEvent.authorId", "=", authorId)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEvent.id", "desc")
.groupBy("CalendarEvent.id")
.limit(5)
@ -484,6 +487,7 @@ type CreateArgs = Pick<
avatarFileName?: string;
avatarImgId?: number;
autoValidateAvatar?: boolean;
parentTournamentId?: number;
};
export async function create(args: CreateArgs) {
const copiedStaff = args.tournamentToCopyId
@ -527,6 +531,7 @@ export async function create(args: CreateArgs) {
.values({
mapPickingStyle: args.mapPickingStyle,
settings: JSON.stringify(settings),
parentTournamentId: args.parentTournamentId,
rules: args.rules,
})
.returning("id")
@ -568,6 +573,7 @@ export async function create(args: CreateArgs) {
bracketUrl: args.bracketUrl,
avatarImgId: args.avatarImgId ?? avatarImgId,
organizationId: args.organizationId,
hidden: args.parentTournamentId ? 1 : 0,
tournamentId,
})
.returning("id")

View File

@ -18,7 +18,7 @@ import { KeyIcon } from "~/components/icons/Key";
import { LogOutIcon } from "~/components/icons/LogOut";
import { SearchIcon } from "~/components/icons/Search";
import { UsersIcon } from "~/components/icons/Users";
import navItems from "~/components/layout/nav-items.json";
import { navItems } from "~/components/layout/nav-items";
import { useUser } from "~/features/auth/core/user";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import {
@ -34,6 +34,7 @@ import {
BLANK_IMAGE_URL,
CALENDAR_TOURNAMENTS_PAGE,
LOG_OUT_URL,
LUTI_PAGE,
SENDOUQ_PAGE,
leaderboardsPage,
navIconUrl,
@ -55,6 +56,7 @@ export const handle: SendouRouteHandle = {
export default function FrontPage() {
return (
<Main className="front-page__container">
<LeagueBanner />
<DesktopSideNav />
<SeasonBanner />
<TournamentCards />
@ -158,6 +160,19 @@ function SeasonBanner() {
);
}
function LeagueBanner() {
const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON;
if (!showBannerFor) return null;
return (
<Link to={LUTI_PAGE} className="front__luti-banner">
<Image path={navIconUrl("luti")} size={24} alt="" />
Registration now open for Leagues Under The Ink (LUTI) Season{" "}
{showBannerFor}!
</Link>
);
}
function TournamentCards() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();

View File

@ -1,5 +1,7 @@
import clsx from "clsx";
import type { TournamentRoundMaps } from "~/db/tables";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useDeadline } from "./useDeadline";
@ -17,6 +19,8 @@ export function RoundHeader({
showInfos?: boolean;
maps?: TournamentRoundMaps | null;
}) {
const leagueRoundStartDate = useLeagueWeekStart(roundId);
const hasDeadline = ![
"WB Finals",
"Grand Finals",
@ -36,7 +40,7 @@ export function RoundHeader({
return (
<div>
<div className="elim-bracket__round-header">{name}</div>
{showInfos && bestOf ? (
{showInfos && bestOf && !leagueRoundStartDate ? (
<div className="elim-bracket__round-header__infos">
<div>
{countPrefix}
@ -45,6 +49,16 @@ export function RoundHeader({
</div>
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
</div>
) : leagueRoundStartDate ? (
<div className="elim-bracket__round-header__infos">
<div>
{leagueRoundStartDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}{" "}
</div>
</div>
) : (
<div className="elim-bracket__round-header__infos invisible">
Hidden
@ -75,3 +89,9 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
</div>
);
}
function useLeagueWeekStart(roundId: number) {
const tournament = useTournament();
return resolveLeagueRoundStartDate(tournament, roundId);
}

View File

@ -6,6 +6,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import { EditIcon } from "~/components/icons/Edit";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import invariant from "~/utils/invariant";
import * as PickBan from "../core/PickBan";
import type { TournamentDataTeam } from "../core/Tournament.server";
@ -223,6 +224,7 @@ function ReportScoreButtons({
matchLocked: boolean;
newScore: [number, number];
}) {
const data = useLoaderData<TournamentMatchLoaderData>();
const user = useUser();
const tournament = useTournament();
const confirmCheckId = React.useId();
@ -230,6 +232,18 @@ function ReportScoreButtons({
const [endConfirmation, setEndConfirmation] = React.useState(false);
const [pointConfirmation, setPointConfirmation] = React.useState(false);
const leagueRoundStartDate = resolveLeagueRoundStartDate(
tournament,
data.match.roundId,
);
if (leagueRoundStartDate && leagueRoundStartDate > new Date()) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
League round has not started yet
</p>
);
}
if (matchLocked) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">

View File

@ -14,6 +14,7 @@ import { PickIcon } from "~/components/icons/Pick";
import { useUser } from "~/features/auth/core/user";
import { Chat, useChat } from "~/features/chat/components/Chat";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { StageId } from "~/modules/in-game-lists";
@ -311,6 +312,14 @@ function FancyStageBanner({
return null;
})();
const waitingForLeagueRoundToStart = (() => {
const date = resolveLeagueRoundStartDate(tournament, data.match.roundId);
if (!date) return false;
return date > new Date();
})();
return (
<>
{inBanPhase ? (
@ -337,6 +346,22 @@ function FancyStageBanner({
<div>Please wait for staff to unlock</div>
</div>
</div>
) : waitingForLeagueRoundToStart ? (
<div className="tournament-bracket__locked-banner">
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
Waiting for league round to start
</div>
<div>
Round playable from{" "}
{resolveLeagueRoundStartDate(
tournament,
data.match.roundId,
)!.toLocaleDateString()}{" "}
onwards
</div>
</div>
</div>
) : waitingForActiveRosterSelectionFor ? (
<div className="tournament-bracket__locked-banner">
<div className="stack sm items-center">

View File

@ -6,6 +6,7 @@ import type {
import { TOURNAMENT } from "~/features/tournament";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Standings from "~/features/tournament/core/Standings";
import { LEAGUES } from "~/features/tournament/tournament-constants";
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Match, Stage } from "~/modules/brackets-model";
@ -588,7 +589,9 @@ export class Tournament {
return {
groupCount: Math.ceil(participantsCount / teamsPerGroup),
seedOrdering: ["groups.seed_optimized"],
seedOrdering: [
this.isLeagueDivision ? "natural" : "groups.seed_optimized",
],
};
}
case "swiss": {
@ -903,6 +906,16 @@ export class Tournament {
return this.ctx.settings.autonomousSubs ?? true;
}
get isLeagueSignup() {
return Object.values(LEAGUES)
.flat()
.some((entry) => entry.tournamentId === this.ctx.id);
}
get isLeagueDivision() {
return Boolean(this.ctx.parentTournamentId);
}
matchNameById(matchId: number) {
let bracketName: string | undefined;
let roundName: string | undefined;

View File

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

View File

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

View File

@ -1979,6 +1979,7 @@ export const PADDLING_POOL_257 = () =>
ctx: {
id: 27,
organization: null,
parentTournamentId: null,
tags: null,
eventId: 1352,
bracketProgressionOverrides: [],
@ -7916,6 +7917,7 @@ export const PADDLING_POOL_255 = () =>
ctx: {
id: 18,
organization: null,
parentTournamentId: null,
tags: null,
eventId: 1286,
bracketProgressionOverrides: [],
@ -14188,6 +14190,7 @@ export const IN_THE_ZONE_32 = ({
},
ctx: {
id: 11,
parentTournamentId: null,
organization: null,
tags: null,
eventId: 1134,

View File

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

View File

@ -0,0 +1,49 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { tournamentIdFromParams } from "~/features/tournament/tournament-utils";
import { notFoundIfFalsy } from "~/utils/remix.server";
import type { Unwrapped } from "../../../utils/types";
import { tournamentFromDB } from "../core/Tournament.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await getUser(request);
const tournamentId = tournamentIdFromParams(params);
const divisions = notFoundIfFalsy(await divisionsCached(tournamentId));
return {
divisions,
divsParticipantOf: user
? divisions
.filter((division) => division.participantUserIds.has(user?.id))
.map((division) => division.tournamentId)
: [],
};
};
// no purge mechanism in code but new divisions are created so rarely we just reboot the server when it is done
const tournamentDivisionsCache = new Map<
number,
Array<Unwrapped<typeof TournamentRepository.findChildTournaments>>
>();
async function divisionsCached(tournamentId: number) {
if (!tournamentDivisionsCache.has(tournamentId)) {
const tournament = await tournamentFromDB({
tournamentId,
user: undefined,
});
if (!tournament.isLeagueSignup) {
return null;
}
tournamentDivisionsCache.set(
tournamentId,
await TournamentRepository.findChildTournaments(tournamentId),
);
}
return tournamentDivisionsCache.get(tournamentId)!;
}

View File

@ -18,6 +18,7 @@ const stm = sql.prepare(/* sql */ `
"TournamentMatch"."bestOf",
"TournamentMatch"."chatCode",
"Tournament"."mapPickingStyle",
"TournamentRound"."id" as "roundId",
"TournamentRound"."maps" as "roundMaps",
json_group_array(
json_object(
@ -60,6 +61,7 @@ export const findMatchById = (id: number) => {
"id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
> &
Pick<Tournament, "mapPickingStyle"> & { players: string }) & {
roundId: number;
roundMaps: string | null;
})
| undefined;
@ -73,6 +75,7 @@ export const findMatchById = (id: number) => {
return {
...row,
bestOf: (roundMaps?.count ?? row.bestOf) as 3 | 5 | 7,
roundId: row.roundId,
roundMaps,
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],

View File

@ -430,6 +430,10 @@ export default function TournamentBracketsPage() {
).length;
};
if (tournament.isLeagueSignup) {
return null;
}
return (
<div>
{visibility !== "hidden" && !tournament.everyBracketOver ? (

View File

@ -0,0 +1,55 @@
import type { SerializeFrom } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { UsersIcon } from "../../../components/icons/Users";
import { tournamentBracketsPage } from "../../../utils/urls";
import { loader } from "../loader/to.$id.divisions.server";
export { loader };
export default function TournamentDivisionsPage() {
const data = useLoaderData<typeof loader>();
if (data.divisions.length === 0) {
return (
<div className="text-center text-lg font-semi-bold text-lighter">
Divisions have not been released yet, check back later
</div>
);
}
return (
<div className="stack horizontal md flex-wrap">
{data.divisions.map((div) => (
<DivisionLink key={div.tournamentId} div={div} />
))}
</div>
);
}
function DivisionLink({
div,
}: { div: SerializeFrom<typeof loader>["divisions"][number] }) {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["calendar"]);
const shortName = div.name.split("-").at(-1);
return (
<Link
to={tournamentBracketsPage({ tournamentId: div.tournamentId })}
className={clsx("tournament__div__link", {
tournament__div__link__participant: data.divsParticipantOf.includes(
div.tournamentId,
),
})}
>
{shortName}
<div className="tournament__div__participant-counts">
<UsersIcon />{" "}
{t("calendar:count.teams", {
count: div.teamsCount,
})}
</div>
</Link>
);
}

View File

@ -46,6 +46,7 @@ export async function findById(id: number) {
"Tournament.castedMatchesInfo",
"Tournament.mapPickingStyle",
"Tournament.rules",
"Tournament.parentTournamentId",
"CalendarEvent.name",
"CalendarEvent.description",
"CalendarEventDate.startTime",
@ -321,6 +322,40 @@ function nullifyingAvg(values: number[]) {
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
}
export async function findChildTournaments(parentTournamentId: number) {
const rows = await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.select((eb) => [
"Tournament.id as tournamentId",
"CalendarEvent.name",
eb
.selectFrom("TournamentTeam")
.select(({ fn }) => [fn.countAll<number>().as("teamsCount")])
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.as("teamsCount"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select(["TournamentTeamMember.userId"])
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id"),
).as("teamMembers"),
])
.where("Tournament.parentTournamentId", "=", parentTournamentId)
.$narrowType<{ teamsCount: NotNull }>()
.execute();
return rows.map((row) => ({
...row,
participantUserIds: new Set(row.teamMembers.map((member) => member.userId)),
}));
}
export async function findTOSetMapPoolById(tournamentId: number) {
return (
await db

View File

@ -7,6 +7,7 @@ import { INVITE_CODE_LENGTH } from "~/constants";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import invariant from "~/utils/invariant";
export function setActiveRoster({
teamId,
@ -141,6 +142,89 @@ export function create({
});
}
export function copyFromAnotherTournament({
tournamentTeamId,
destinationTournamentId,
}: { tournamentTeamId: number; destinationTournamentId: number }) {
return db.transaction().execute(async (trx) => {
const oldTeam = await trx
.selectFrom("TournamentTeam")
.select([
"TournamentTeam.avatarImgId",
"TournamentTeam.createdAt",
"TournamentTeam.name",
"TournamentTeam.noScreen",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.teamId",
// -- exclude these
// "TournamentTeam.id"
// "TournamentTeam.droppedOut"
// "TournamentTeam.activeRosterUserIds"
// "TournamentTeam.seed"
// "TournamentTeam.startingBracketIdx"
// "TournamentTeam.inviteCode"
// "TournamentTeam.tournamentId"
// "TournamentTeam.activeRosterUserIds",
])
.where("id", "=", tournamentTeamId)
.executeTakeFirstOrThrow();
const oldMembers = await trx
.selectFrom("TournamentTeamMember")
.select([
"TournamentTeamMember.createdAt",
"TournamentTeamMember.inGameName",
"TournamentTeamMember.isOwner",
"TournamentTeamMember.userId",
// -- exclude these
// "TournamentTeamMember.tournamentTeamId"
])
.where("tournamentTeamId", "=", tournamentTeamId)
.execute();
invariant(oldMembers.length > 0, "Team has no members");
const oldMapPool = await trx
.selectFrom("MapPoolMap")
.select(["MapPoolMap.mode", "MapPoolMap.stageId"])
.where("tournamentTeamId", "=", tournamentTeamId)
.execute();
const newTeam = await trx
.insertInto("TournamentTeam")
.values({
...oldTeam,
tournamentId: destinationTournamentId,
inviteCode: nanoid(INVITE_CODE_LENGTH),
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("TournamentTeamMember")
.values(
oldMembers.map((member) => ({
...member,
tournamentTeamId: newTeam.id,
})),
)
.execute();
if (oldMapPool.length > 0) {
await trx
.insertInto("MapPoolMap")
.values(
oldMapPool.map((mapPoolMap) => ({
...mapPoolMap,
tournamentTeamId: newTeam.id,
})),
)
.execute();
}
});
}
export function update({
team,
avatarFileName,

View File

@ -255,6 +255,10 @@ export const action: ActionFunction = async ({ request, params }) => {
case "UNREGISTER": {
validate(ownTeam, "You are not registered to this tournament");
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
validate(
!tournament.isLeagueSignup || tournament.registrationOpen,
"Unregistering from leagues is not possible after registration has closed",
);
deleteTeam(ownTeam.id);

View File

@ -0,0 +1,12 @@
import { redirect } from "@remix-run/node";
import { notFoundIfFalsy } from "../../../utils/remix.server";
import { tournamentPage } from "../../../utils/urls";
import { LEAGUES } from "../tournament-constants";
const maybeLatest = LEAGUES.LUTI?.at(-1);
export const loader = () => {
const latest = notFoundIfFalsy(maybeLatest);
return redirect(tournamentPage(latest.tournamentId));
};

View File

@ -23,6 +23,7 @@ import invariant from "~/utils/invariant";
import { assertUnreachable } from "~/utils/types";
import {
calendarEventPage,
teamPage,
tournamentEditPage,
tournamentPage,
} from "~/utils/urls";
@ -62,22 +63,24 @@ export default function TournamentAdminPage() {
>
Edit event info
</LinkButton>
<FormWithConfirm
dialogHeading={t("calendar:actions.delete.confirm", {
name: tournament.ctx.name,
})}
action={calendarEventPage(tournament.ctx.eventId)}
submitButtonTestId="delete-submit-button"
>
<Button
className="ml-auto"
size="tiny"
variant="minimal-destructive"
type="submit"
{!tournament.isLeagueSignup ? (
<FormWithConfirm
dialogHeading={t("calendar:actions.delete.confirm", {
name: tournament.ctx.name,
})}
action={calendarEventPage(tournament.ctx.eventId)}
submitButtonTestId="delete-submit-button"
>
{t("calendar:actions.delete")}
</Button>
</FormWithConfirm>
<Button
className="ml-auto"
size="tiny"
variant="minimal-destructive"
type="submit"
>
{t("calendar:actions.delete")}
</Button>
</FormWithConfirm>
) : null}
</div>
) : null}
{tournament.isAdmin(user) &&
@ -111,8 +114,12 @@ export default function TournamentAdminPage() {
<CastTwitchAccounts />
<Divider smallText>Participant list download</Divider>
<DownloadParticipants />
<Divider smallText>Bracket reset</Divider>
<BracketReset />
{!tournament.isLeagueSignup ? (
<>
<Divider smallText>Bracket reset</Divider>
<BracketReset />
</>
) : null}
</div>
);
}
@ -609,6 +616,35 @@ function DownloadParticipants() {
.join("\n");
}
function leagueFormat() {
const memberColumnsCount = tournament.ctx.teams.reduce(
(max, team) => Math.max(max, team.members.length),
0,
);
const header = `Team id,Team name,Team page URL,Div${Array.from({
length: memberColumnsCount,
})
.map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`)
.join("")}`;
return `${header}\n${tournament.ctx.teams
.map((team) => {
return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members
.map(
(member) =>
`${member.username},https://sendou.ink/u/${member.discordId}`,
)
.join(",")}${Array(
memberColumnsCount - team.members.length === 0
? 0
: memberColumnsCount - team.members.length + 1,
)
.fill(",")
.join("")}`;
})
.join("\n")}`;
}
return (
<div>
<div className="stack horizontal sm flex-wrap">
@ -656,6 +692,19 @@ function DownloadParticipants() {
>
Simple list in seeded order
</Button>
{tournament.isLeagueSignup ? (
<Button
size="tiny"
onClick={() =>
handleDownload({
filename: "league-format.csv",
content: leagueFormat(),
})
}
>
League format
</Button>
) : null}
</div>
</div>
);

View File

@ -119,20 +119,22 @@ export default function TournamentRegisterPage() {
</Link>
)}
</div>
<div className="tournament__by mt-2">
<div className="stack horizontal xs items-center">
<ClockIcon className="tournament__info__icon" />{" "}
{isMounted
? tournament.ctx.startTime.toLocaleString(i18n.language, {
timeZoneName: "short",
minute: startsAtEvenHour ? undefined : "numeric",
hour: "numeric",
day: "numeric",
month: "long",
})
: null}
{!tournament.isLeagueSignup ? (
<div className="tournament__by mt-2">
<div className="stack horizontal xs items-center">
<ClockIcon className="tournament__info__icon" />{" "}
{isMounted
? tournament.ctx.startTime.toLocaleString(i18n.language, {
timeZoneName: "short",
minute: startsAtEvenHour ? undefined : "numeric",
hour: "numeric",
day: "numeric",
month: "long",
})
: null}
</div>
</div>
</div>
) : null}
<div className="stack horizontal sm mt-1">
{tournament.ranked ? (
<div className="tournament__badge tournament__badge__ranked">
@ -411,10 +413,12 @@ function RegistrationProgress({
completed: mapPool && mapPool.length > 0,
}
: null,
{
name: t("tournament:pre.steps.check-in"),
completed: checkedIn,
},
!tournament.isLeagueSignup
? {
name: t("tournament:pre.steps.check-in"),
completed: checkedIn,
}
: null,
]);
const regClosesBeforeStart =
@ -422,7 +426,10 @@ function RegistrationProgress({
tournament.ctx.startTime.getTime();
const registrationClosesAtString = isMounted
? tournament.registrationClosesAt.toLocaleTimeString(i18n.language, {
? (tournament.isLeagueSignup
? tournament.ctx.startTime
: tournament.registrationClosesAt
).toLocaleTimeString(i18n.language, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
@ -456,22 +463,24 @@ function RegistrationProgress({
);
})}
</div>
<CheckIn
canCheckIn={steps.filter((step) => !step.completed).length === 1}
status={
tournament.regularCheckInIsOpen
? "OPEN"
: tournament.regularCheckInHasEnded
? "OVER"
: "UPCOMING"
}
startDate={tournament.regularCheckInStartsAt}
endDate={tournament.regularCheckInEndsAt}
checkedIn={checkedIn}
/>
{!tournament.isLeagueSignup ? (
<CheckIn
canCheckIn={steps.filter((step) => !step.completed).length === 1}
status={
tournament.regularCheckInIsOpen
? "OPEN"
: tournament.regularCheckInHasEnded
? "OVER"
: "UPCOMING"
}
startDate={tournament.regularCheckInStartsAt}
endDate={tournament.regularCheckInEndsAt}
checkedIn={checkedIn}
/>
) : null}
</section>
<div className="tournament__section__warning">
{regClosesBeforeStart ? (
{regClosesBeforeStart || tournament.isLeagueSignup ? (
<span className="text-warning">
Registration closes at {registrationClosesAtString}
</span>
@ -672,6 +681,17 @@ function TeamInfo({
</Button>
</FormWithConfirm>
) : null}
{canUnregister &&
tournament.isLeagueSignup &&
!tournament.registrationOpen ? (
<Popover
triggerClassName="minimal-destructive tiny build__small-text"
buttonChildren={t("tournament:pre.info.unregister")}
>
Unregistration from a league after the registration has ended is
handled by the organizers
</Popover>
) : null}
</div>
<section className="tournament__section">
<Form method="post" className="stack md items-center" ref={ref}>

View File

@ -25,8 +25,10 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
tournamentDivisionsPage,
tournamentOrganizationPage,
tournamentPage,
tournamentRegisterPage,
userSubmittedImage,
} from "~/utils/urls";
import { streamsByTournamentId } from "../core/streams.server";
@ -245,12 +247,37 @@ export function TournamentLayout() {
return (
<Main bigger>
<SubNav>
<SubNavLink to="register" data-testid="register-tab" prefetch="intent">
{tournament.hasStarted ? "Info" : t("tournament:tabs.register")}
</SubNavLink>
<SubNavLink to="brackets" data-testid="brackets-tab" prefetch="render">
{t("tournament:tabs.brackets")}
<SubNavLink
to={tournamentRegisterPage(
tournament.isLeagueDivision
? tournament.ctx.parentTournamentId!
: tournament.ctx.id,
)}
data-testid="register-tab"
prefetch="intent"
>
{tournament.hasStarted || tournament.isLeagueDivision
? "Info"
: t("tournament:tabs.register")}
</SubNavLink>
{!tournament.isLeagueSignup ? (
<SubNavLink
to="brackets"
data-testid="brackets-tab"
prefetch="render"
>
{t("tournament:tabs.brackets")}
</SubNavLink>
) : null}
{tournament.isLeagueSignup || tournament.isLeagueDivision ? (
<SubNavLink
to={tournamentDivisionsPage(
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
)}
>
Divisions
</SubNavLink>
) : null}
<SubNavLink
to="teams"
end={false}
@ -276,9 +303,11 @@ export function TournamentLayout() {
{t("tournament:tabs.results")}
</SubNavLink>
) : null}
{tournament.isOrganizer(user) && !tournament.hasStarted && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{tournament.isOrganizer(user) &&
!tournament.hasStarted &&
!tournament.isLeagueSignup && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}

View File

@ -14,3 +14,28 @@ export const TOURNAMENT = {
SWISS_DEFAULT_GROUP_COUNT: 1,
SWISS_DEFAULT_ROUND_COUNT: 5,
} as const;
export const LEAGUES =
process.env.NODE_ENV === "development"
? {
LUTI: [
{
tournamentId: 6,
weeks: [
{
weekNumber: 2,
year: 2025,
},
{
weekNumber: 3,
year: 2025,
},
{
weekNumber: 4,
year: 2025,
},
],
},
],
}
: {};

View File

@ -2,14 +2,16 @@ import type { Params } from "@remix-run/react";
import type { Tournament } from "~/db/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { weekNumberToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { tournamentLogoUrl } from "~/utils/urls";
import { MapPool } from "../map-list-generator/core/map-pool";
import { currentSeason } from "../mmr/season";
import { BANNED_MAPS } from "../sendouq-settings/banned-maps";
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";
import type { TournamentData } from "../tournament-bracket/core/Tournament.server";
import type { PlayedSet } from "./core/sets.server";
import { TOURNAMENT } from "./tournament-constants";
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
export function tournamentIdFromParams(params: Params<string>) {
const result = Number(params.id);
@ -269,3 +271,39 @@ export function tournamentIsRanked({
return isSetAsRanked ?? true;
}
export function resolveLeagueRoundStartDate(
tournament: TournamentClass,
roundId: number,
) {
if (!tournament.isLeagueDivision) return null;
const league = Object.values(LEAGUES)
.flat()
.find(
(league) => league.tournamentId === tournament.ctx.parentTournamentId,
);
if (!league) return null;
const bracket = tournament.brackets.find((b) =>
b.data.round.some((r) => r.id === roundId),
);
const round = bracket?.data.round.find((r) => r.id === roundId);
const onlyRelevantRounds = bracket?.data.round.filter(
(r) => r.group_id === round?.group_id,
);
const roundIdx = onlyRelevantRounds?.findIndex((r) => r.id === roundId);
if (roundIdx === undefined) return null;
const week = league.weeks[roundIdx];
if (!week) return null;
const date = weekNumberToDate({
week: week.weekNumber,
year: week.year,
});
return date;
}

View File

@ -651,3 +651,39 @@
background-color: var(--theme-transparent);
border-radius: var(--rounded);
}
.tournament__div__link {
background-color: var(--bg-lighter-solid);
padding: var(--s-2) var(--s-4);
border-radius: var(--rounded);
width: max-content;
font-size: var(--fonts-lg);
}
.tournament__div__link:focus-visible {
outline: 3px solid var(--theme);
outline-offset: 3px;
}
.tournament__div__link__participant {
outline: 3px solid var(--bg-lightest-solid);
outline-offset: 3px;
}
.tournament__div__link__participant svg {
fill: var(--theme);
}
.tournament__div__participant-counts {
display: flex;
align-items: center;
color: var(--text-lighter);
font-size: var(--fonts-sm);
gap: var(--s-2);
margin-block-end: var(--s-1);
}
.tournament__div__participant-counts > svg {
width: 1rem;
margin-block-end: 1px;
}

View File

@ -79,6 +79,10 @@ export default [
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"),
route(
"divisions",
"features/tournament-bracket/routes/to.$id.divisions.tsx",
),
route(
"brackets/subscribe",
"features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx",
@ -92,6 +96,7 @@ export default [
"features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx",
),
]),
route("luti", "features/tournament/routes/luti.tsx"),
...prefix("/org/:slug", [
index("features/tournament-organization/routes/org.$slug.tsx"),

View File

@ -347,3 +347,14 @@
gap: var(--s-1);
padding: var(--s-0-5) var(--s-1-5);
}
.front__luti-banner {
background-color: #4874a0;
color: #fff;
border-radius: var(--rounded);
padding: var(--s-2);
justify-content: center;
display: flex;
gap: var(--s-2);
flex-wrap: wrap;
}

View File

@ -8,7 +8,7 @@ import type { Params, UIMatch } from "@remix-run/react";
import type { Namespace, TFunction } from "i18next";
import { nanoid } from "nanoid";
import { z } from "zod";
import type navItems from "~/components/layout/nav-items.json";
import type { navItems } from "~/components/layout/nav-items";
import { s3UploadHandler } from "~/features/img-upload";
import invariant from "./invariant";
@ -247,7 +247,7 @@ export type SendouRouteHandle = {
t: TFunction<"common", undefined>;
}) => Breadcrumb | Array<Breadcrumb> | undefined;
/** The name of a navItem that is active on this route. See nav-items.json */
/** The name of a navItem that is active on this route. See nav-items.ts */
navItemName?: (typeof navItems)[number]["name"];
};

View File

@ -1,5 +1,4 @@
import slugify from "slugify";
import type navItems from "~/components/layout/nav-items.json";
import type { Preference } from "~/db/tables";
import type {
Art,
@ -135,6 +134,7 @@ export const TIERS_PAGE = "/tiers";
export const SUSPENDED_PAGE = "/suspended";
export const LFG_PAGE = "/lfg";
export const SETTINGS_PAGE = "/settings";
export const LUTI_PAGE = "/luti";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@ -314,6 +314,8 @@ export const tournamentBracketsPage = ({
query.size > 0 ? `?${query.toString()}` : ""
}`;
};
export const tournamentDivisionsPage = (tournamentId: number) =>
`/to/${tournamentId}/divisions`;
export const tournamentResultsPage = (tournamentId: number) =>
`/to/${tournamentId}/results`;
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
@ -420,7 +422,7 @@ export const badgeUrl = ({
export const articlePreviewUrl = (slug: string) =>
`/static-assets/img/article-previews/${slug}.png`;
export const navIconUrl = (navItem: (typeof navItems)[number]["name"]) =>
export const navIconUrl = (navItem: string) =>
`/static-assets/img/layout/${navItem}`;
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
`/static-assets/img/gear/${gearType.toLowerCase()}/${gearSplId}`;

View File

@ -0,0 +1,14 @@
Leagues are a variety of sendou.ink tournaments where each division is a separate competition. Participants sign-up to one "entry tournament" then get divided into divisions by the organizers and one winner emerges per division.
## Creating a league
Note: leagues are not an open feature available for everyone and require some amount of manual admin work from Sendou.
1) Create tournament as normal on sendou.ink. This will be the tournament where users sign up to. Set registration closing time as you wish.
2) Link the bracket to sendou.
3) Once the registration closes from the admin tab download participant list as "league format" (.csv file).
4) Order the teams as you wish. Fill the "div" column with the desired division per participant.
5) Give sendou back the .csv file.
6) Division brackets will be generated. You get the chance to edit each as you wish before starting it. If you want to use the same maps for each division, just do them for one bracket then let Sendou know. There is an admin script that can be ran that copies them across all divisions.
7) (Optional): let Sendou know the start time of each bracket to control during which week which round of groups is played.
8) When everything looks good start divisions and play out the tournament as normal.

View File

@ -24,6 +24,7 @@
"pages.sendouq": "SendouQ",
"pages.lfg": "LFG",
"pages.settings": "Settings",
"pages.luti": "LUTI",
"header.profile": "Profile",
"header.logout": "Log out",

View File

@ -0,0 +1,11 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "Tournament" add "parentTournamentId" integer references "Tournament"("id") on delete restrict`,
).run();
db.prepare(
/* sql */ `alter table "CalendarEvent" add "hidden" integer default 0`,
).run();
})();
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,184 @@
// for testing use the command `npx tsx ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/Sendouc/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'`
import "dotenv/config";
import { z } from "zod";
import { ADMIN_ID } from "~/constants";
import { db } from "~/db/sql";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const tournamentId = Number(process.argv[2]?.trim());
invariant(
tournamentId && !Number.isNaN(tournamentId),
"tournament id is required (argument 1)",
);
const csvUrl = process.argv[3]?.trim();
invariant(z.string().url().parse(csvUrl), "csv url is required (argument 2)");
async function main() {
console.time("create-league-divisions");
const tournament = await tournamentFromDB({
tournamentId,
user: { id: ADMIN_ID },
});
invariant(tournament.isLeagueSignup, "Tournament is not a league signup");
const csv = await loadCsv();
const teams = parseCsv(csv);
for (const team of teams) {
validateTeam(team, tournament);
}
validateDivs(teams);
const grouped = Object.entries(Object.groupBy(teams, (t) => t.division)).sort(
(a, b) => {
const divAIndex = teams.findIndex((t) => t.division === a[0]);
const divBIndex = teams.findIndex((t) => t.division === b[0]);
return divAIndex - divBIndex;
},
);
for (const [, divsTeams] of grouped) {
divsTeams!.sort((a, b) => {
const teamAIndex = teams.findIndex((t) => t.id === a.id);
const teamBIndex = teams.findIndex((t) => t.id === b.id);
return teamAIndex - teamBIndex;
});
}
const calendarEvent = await db
.selectFrom("CalendarEvent")
.selectAll()
.where("CalendarEvent.id", "=", tournament.ctx.eventId)
.executeTakeFirstOrThrow();
for (const [div, divsTeams] of grouped) {
logger.info(`Creating division ${div}...`);
const createdEvent = await CalendarRepository.create({
parentTournamentId: tournament.ctx.id,
authorId: tournament.ctx.author.id,
bracketProgression: tournament.ctx.settings.bracketProgression,
description: tournament.ctx.description,
deadlines: tournament.ctx.settings.deadlines,
discordInviteCode:
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
mapPickingStyle: tournament.ctx.mapPickingStyle,
name: `${tournament.ctx.name} - Division ${div}`,
organizationId: tournament.ctx.organization?.id ?? null,
rules: tournament.ctx.rules,
startTimes: [dateToDatabaseTimestamp(tournament.ctx.startTime)],
tags: null,
tournamentToCopyId: tournament.ctx.id,
avatarImgId: calendarEvent.avatarImgId ?? undefined,
avatarFileName: undefined,
mapPoolMaps:
tournament.ctx.mapPickingStyle !== "TO"
? tournament.ctx.tieBreakerMapPool
: tournament.ctx.toSetMapPool,
badges: [],
enableNoScreenToggle: tournament.ctx.settings.enableNoScreenToggle,
enableSubs: false,
isInvitational: true,
autonomousSubs: false,
isRanked: tournament.ctx.settings.isRanked,
minMembersPerTeam: tournament.ctx.settings.minMembersPerTeam,
regClosesAt: tournament.ctx.settings.regClosesAt,
requireInGameNames: tournament.ctx.settings.requireInGameNames,
bracketUrl: "https://sendou.ink",
isFullTournament: true,
autoValidateAvatar: true,
// these come from progression
swissGroupCount: undefined,
swissRoundCount: undefined,
teamsPerGroup: undefined,
thirdPlaceMatch: undefined,
});
for (const team of divsTeams!) {
await TournamentTeamRepository.copyFromAnotherTournament({
destinationTournamentId: createdEvent.tournamentId!,
tournamentTeamId: team.id,
});
}
logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`);
}
console.timeEnd("create-league-divisions");
}
async function loadCsv() {
const response = await fetch(csvUrl);
return response.text();
}
const csvSchema = z.array(
z.object({
"Team id": z.coerce.number(),
Div: z.string(),
}),
);
type ParsedTeam = ReturnType<typeof parseCsv>[number];
function parseCsv(csv: string) {
const lines = csv.split("\n");
const headers = lines[0].split(",");
const rows = lines.slice(1).map((line) => {
const row = line.split(",");
return headers.reduce(
(acc, header, i) => {
acc[header] = row[i];
return acc;
},
{} as Record<string, string>,
);
});
const validated = csvSchema.parse(rows);
return validated.map((row) => ({
id: row["Team id"],
division: row.Div,
}));
}
function validateTeam(team: ParsedTeam, tournament: Tournament) {
invariant(
tournament.ctx.teams.some((t) => t.id === team.id),
`Team with id ${team.id} not found in tournament`,
);
}
const MIN_TEAMS_COUNT_PER_DIV = 6;
function validateDivs(teams: ParsedTeam[]) {
const counts = teams.reduce(
(acc, team) => {
acc[team.division] = (acc[team.division] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
for (const [div, count] of Object.entries(counts)) {
invariant(
count >= MIN_TEAMS_COUNT_PER_DIV,
`Division ${div} has ${count} teams, expected at least ${MIN_TEAMS_COUNT_PER_DIV}`,
);
}
}
main();