mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
TO Tools back (#1349)
* Remove friend code * Revive TO Tools admin page * Revive TO Tools maps page * Initial one mode only map list * Add modesIncluded arg * Handle no maps picked for SZ only generation * Tiebreaker is always from the maps of the teams * Make modesIncluded necessary arg * Tiebreaker is from neither team's pool if no overlap * Handles worst case duplication * Handles one team submitted no maps test * Fix crash * Seed * Can change one mode tournament map pool * Fix join page link * Remove useless TODO * Fixes related to mapListGeneratorAvailable * Fix map list generation considering impossible map lists making it take forever * Show unlisted select for both sides * Add info texts * Remove register button * Add todos * Finished version for ITZ * Times * Remove TODOs * 23->24
This commit is contained in:
16
app/components/icons/User.tsx
Normal file
16
app/components/icons/User.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function UserIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ insert into
|
||||
"description",
|
||||
"discordInviteCode",
|
||||
"bracketUrl",
|
||||
"toToolsEnabled"
|
||||
"toToolsEnabled",
|
||||
"toToolsMode"
|
||||
)
|
||||
values
|
||||
(
|
||||
@@ -16,5 +17,6 @@ values
|
||||
@description,
|
||||
@discordInviteCode,
|
||||
@bracketUrl,
|
||||
@toToolsEnabled
|
||||
@toToolsEnabled,
|
||||
@toToolsMode
|
||||
) returning *
|
||||
|
||||
@@ -66,6 +66,7 @@ export type CreateArgs = Pick<
|
||||
| "discordInviteCode"
|
||||
| "bracketUrl"
|
||||
| "toToolsEnabled"
|
||||
| "toToolsMode"
|
||||
> & {
|
||||
startTimes: Array<CalendarEventDate["startTime"]>;
|
||||
badges: Array<CalendarEventBadge["badgeId"]>;
|
||||
|
||||
@@ -6,6 +6,7 @@ set
|
||||
"description" = @description,
|
||||
"discordInviteCode" = @discordInviteCode,
|
||||
"bracketUrl" = @bracketUrl,
|
||||
"toToolsEnabled" = @toToolsEnabled
|
||||
"toToolsEnabled" = @toToolsEnabled,
|
||||
"toToolsMode" = @toToolsMode
|
||||
where
|
||||
"id" = @eventId
|
||||
|
||||
@@ -39,6 +39,10 @@ const NZAP_TEST_ID = 2;
|
||||
|
||||
const AMOUNT_OF_CALENDAR_EVENTS = 200;
|
||||
|
||||
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
|
||||
const calendarEventWithToToolsTeamsSz = () =>
|
||||
calendarEventWithToToolsTeams(true);
|
||||
|
||||
const basicSeeds = [
|
||||
adminUser,
|
||||
makeAdminPatron,
|
||||
@@ -61,6 +65,8 @@ const basicSeeds = [
|
||||
calendarEventWithToTools,
|
||||
calendarEventWithToToolsTieBreakerMapPool,
|
||||
calendarEventWithToToolsTeams,
|
||||
calendarEventWithToToolsSz,
|
||||
calendarEventWithToToolsTeamsSz,
|
||||
adminBuilds,
|
||||
manySplattershotBuilds,
|
||||
detailedTeam,
|
||||
@@ -603,7 +609,9 @@ function calendarEventResults() {
|
||||
}
|
||||
|
||||
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
|
||||
function calendarEventWithToTools() {
|
||||
function calendarEventWithToTools(sz?: boolean) {
|
||||
const eventId = TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0);
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
@@ -614,7 +622,8 @@ function calendarEventWithToTools() {
|
||||
"discordInviteCode",
|
||||
"bracketUrl",
|
||||
"authorId",
|
||||
"toToolsEnabled"
|
||||
"toToolsEnabled",
|
||||
"toToolsMode"
|
||||
) values (
|
||||
$id,
|
||||
$name,
|
||||
@@ -622,18 +631,20 @@ function calendarEventWithToTools() {
|
||||
$discordInviteCode,
|
||||
$bracketUrl,
|
||||
$authorId,
|
||||
$toToolsEnabled
|
||||
$toToolsEnabled,
|
||||
$toToolsMode
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
id: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
name: "PICNIC #2",
|
||||
id: eventId,
|
||||
name: sz ? "In The Zone 22" : "PICNIC #2",
|
||||
description: faker.lorem.paragraph(),
|
||||
discordInviteCode: faker.lorem.word(),
|
||||
bracketUrl: faker.internet.url(),
|
||||
authorId: 1,
|
||||
toToolsEnabled: 1,
|
||||
toToolsMode: sz ? "SZ" : null,
|
||||
});
|
||||
|
||||
sql
|
||||
@@ -649,7 +660,7 @@ function calendarEventWithToTools() {
|
||||
`
|
||||
)
|
||||
.run({
|
||||
eventId: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
eventId,
|
||||
startTime: dateToDatabaseTimestamp(new Date()),
|
||||
});
|
||||
}
|
||||
@@ -693,7 +704,7 @@ const availablePairs = rankedModesShort
|
||||
availableStages.map((stageId) => ({ mode, stageId: stageId }))
|
||||
)
|
||||
.filter((pair) => !tiebreakerPicks.has(pair));
|
||||
function calendarEventWithToToolsTeams() {
|
||||
function calendarEventWithToToolsTeams(sz?: boolean) {
|
||||
const userIds = userIdsInRandomOrder(true);
|
||||
for (let id = 1; id <= 40; id++) {
|
||||
sql
|
||||
@@ -715,10 +726,10 @@ function calendarEventWithToToolsTeams() {
|
||||
`
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
id: id + (sz ? 100 : 0),
|
||||
name: names.pop(),
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0),
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
});
|
||||
|
||||
@@ -744,7 +755,7 @@ function calendarEventWithToToolsTeams() {
|
||||
`
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: id,
|
||||
tournamentTeamId: id + (sz ? 100 : 0),
|
||||
userId: userIds.pop()!,
|
||||
isOwner: i === 0 ? 1 : 0,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
@@ -761,12 +772,14 @@ function calendarEventWithToToolsTeams() {
|
||||
const stageUsedCounts: Partial<Record<StageId, number>> = {};
|
||||
|
||||
for (const pair of shuffledPairs) {
|
||||
if (pair.mode === "SZ" && SZ >= 2) continue;
|
||||
if (sz && pair.mode !== "SZ") continue;
|
||||
|
||||
if (pair.mode === "SZ" && SZ >= (sz ? 6 : 2)) continue;
|
||||
if (pair.mode === "TC" && TC >= 2) continue;
|
||||
if (pair.mode === "RM" && RM >= 2) continue;
|
||||
if (pair.mode === "CB" && CB >= 2) continue;
|
||||
|
||||
if (stageUsedCounts[pair.stageId] === 2) continue;
|
||||
if (stageUsedCounts[pair.stageId] === (sz ? 1 : 2)) continue;
|
||||
|
||||
stageUsedCounts[pair.stageId] =
|
||||
(stageUsedCounts[pair.stageId] ?? 0) + 1;
|
||||
@@ -786,7 +799,7 @@ function calendarEventWithToToolsTeams() {
|
||||
`
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: id,
|
||||
tournamentTeamId: id + (sz ? 100 : 0),
|
||||
stageId: pair.stageId,
|
||||
mode: pair.mode,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface CalendarEvent {
|
||||
customUrl: string | null;
|
||||
/** Is tournament tools page visible */
|
||||
toToolsEnabled: number;
|
||||
toToolsMode: RankedModeShort | null;
|
||||
isBeforeStart: number;
|
||||
}
|
||||
|
||||
export type CalendarEventTag = keyof typeof allTags;
|
||||
@@ -182,8 +184,8 @@ export interface MapPoolMap {
|
||||
|
||||
export interface TournamentTeam {
|
||||
id: number;
|
||||
name: string | null;
|
||||
friendCode: string | null;
|
||||
// TODO: make non-nullable in database as well
|
||||
name: string;
|
||||
createdAt: number;
|
||||
seed: number | null;
|
||||
calendarEventId: number;
|
||||
|
||||
@@ -6,10 +6,12 @@ import { INVITE_CODE_LENGTH } from "~/constants";
|
||||
const createTeamStm = sql.prepare(/*sql*/ `
|
||||
insert into "TournamentTeam" (
|
||||
"calendarEventId",
|
||||
"inviteCode"
|
||||
"inviteCode",
|
||||
"name"
|
||||
) values (
|
||||
@calendarEventId,
|
||||
@inviteCode
|
||||
@inviteCode,
|
||||
@name
|
||||
) returning *
|
||||
`);
|
||||
|
||||
@@ -28,13 +30,16 @@ const createMemberStm = sql.prepare(/*sql*/ `
|
||||
export const createTeam = sql.transaction(
|
||||
({
|
||||
calendarEventId,
|
||||
name,
|
||||
ownerId,
|
||||
}: {
|
||||
calendarEventId: TournamentTeam["calendarEventId"];
|
||||
name: TournamentTeam["name"];
|
||||
ownerId: User["id"];
|
||||
}) => {
|
||||
const team = createTeamStm.get({
|
||||
calendarEventId,
|
||||
name,
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
}) as TournamentTeam;
|
||||
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type { CalendarEvent, User } from "~/db/types";
|
||||
import type { CalendarEvent, CalendarEventDate, User } from "~/db/types";
|
||||
|
||||
// TODO: doesn't work if many start times
|
||||
const stm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
"CalendarEvent"."name",
|
||||
"CalendarEvent"."description",
|
||||
"CalendarEvent"."id",
|
||||
"CalendarEvent"."bracketUrl",
|
||||
"CalendarEvent"."authorId",
|
||||
"User"."discordName",
|
||||
"User"."discordDiscriminator",
|
||||
"User"."discordId"
|
||||
"CalendarEvent"."id",
|
||||
"CalendarEvent"."bracketUrl",
|
||||
"CalendarEvent"."authorId",
|
||||
"CalendarEvent"."isBeforeStart",
|
||||
"CalendarEvent"."toToolsMode",
|
||||
"CalendarEventDate"."startTime",
|
||||
"User"."discordName",
|
||||
"User"."discordDiscriminator",
|
||||
"User"."discordId"
|
||||
from "CalendarEvent"
|
||||
left join "User" on "CalendarEvent"."authorId" = "User"."id"
|
||||
left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
|
||||
where
|
||||
(
|
||||
"CalendarEvent"."id" = @identifier
|
||||
or "CalendarEvent"."customUrl" = @identifier
|
||||
)
|
||||
and "CalendarEvent"."toToolsEnabled" = 1
|
||||
group by "CalendarEvent"."id"
|
||||
`);
|
||||
|
||||
type FindByIdentifierRow =
|
||||
| (Pick<
|
||||
CalendarEvent,
|
||||
"bracketUrl" | "id" | "name" | "description" | "authorId"
|
||||
| "bracketUrl"
|
||||
| "id"
|
||||
| "name"
|
||||
| "description"
|
||||
| "authorId"
|
||||
| "isBeforeStart"
|
||||
| "toToolsMode"
|
||||
> &
|
||||
Pick<User, "discordId" | "discordName" | "discordDiscriminator">)
|
||||
Pick<User, "discordId" | "discordName" | "discordDiscriminator"> &
|
||||
Pick<CalendarEventDate, "startTime">)
|
||||
| null;
|
||||
|
||||
export function findByIdentifier(identifier: string | number) {
|
||||
|
||||
@@ -5,7 +5,6 @@ const stm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
"TournamentTeam"."id",
|
||||
"TournamentTeam"."name",
|
||||
"TournamentTeam"."friendCode",
|
||||
"TournamentTeam"."checkedInAt",
|
||||
"TournamentTeam"."inviteCode"
|
||||
from
|
||||
@@ -20,7 +19,7 @@ const stm = sql.prepare(/*sql*/ `
|
||||
|
||||
type FindOwnTeam = Pick<
|
||||
TournamentTeam,
|
||||
"id" | "name" | "friendCode" | "checkedInAt" | "inviteCode"
|
||||
"id" | "name" | "checkedInAt" | "inviteCode"
|
||||
> | null;
|
||||
|
||||
export function findOwnTeam({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update
|
||||
"CalendarEvent"
|
||||
set
|
||||
"isBeforeStart" = @isBeforeStart
|
||||
where
|
||||
"id" = @id;
|
||||
`);
|
||||
|
||||
export function updateIsBeforeStart({
|
||||
id,
|
||||
isBeforeStart,
|
||||
}: {
|
||||
id: number;
|
||||
isBeforeStart: number;
|
||||
}) {
|
||||
return stm.run({ id, isBeforeStart });
|
||||
}
|
||||
@@ -5,8 +5,7 @@ const stm = sql.prepare(/*sql*/ `
|
||||
update
|
||||
"TournamentTeam"
|
||||
set
|
||||
"name" = @name,
|
||||
"friendCode" = @friendCode
|
||||
"name" = @name
|
||||
where
|
||||
"id" = @id
|
||||
`);
|
||||
@@ -14,15 +13,12 @@ const stm = sql.prepare(/*sql*/ `
|
||||
export function updateTeamInfo({
|
||||
id,
|
||||
name,
|
||||
friendCode,
|
||||
}: {
|
||||
id: TournamentTeam["id"];
|
||||
name: TournamentTeam["name"];
|
||||
friendCode: TournamentTeam["friendCode"];
|
||||
}) {
|
||||
stm.run({
|
||||
id,
|
||||
name,
|
||||
friendCode,
|
||||
});
|
||||
}
|
||||
|
||||
139
app/features/tournament/routes/to.$id.admin.tsx
Normal file
139
app/features/tournament/routes/to.$id.admin.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { LoaderArgs, ActionFunction } from "@remix-run/node";
|
||||
import { useLoaderData, useSubmit } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { canAdminCalendarTOTools } from "~/permissions";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { checkboxValueToBoolean } from "~/utils/zod";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
|
||||
import { updateIsBeforeStart } from "../queries/updateIsBeforeStart.server";
|
||||
import { requireUserId } from "~/modules/auth/user.server";
|
||||
import { idFromParams } from "../tournament-utils";
|
||||
|
||||
const tournamentToolsActionSchema = z.object({
|
||||
started: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: tournamentToolsActionSchema,
|
||||
});
|
||||
|
||||
const eventId = idFromParams(params);
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
|
||||
validate(canAdminCalendarTOTools({ user, event }));
|
||||
|
||||
updateIsBeforeStart({
|
||||
id: event.id,
|
||||
isBeforeStart: Number(!data.started),
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const eventId = idFromParams(params);
|
||||
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
notFoundIfFalsy(canAdminCalendarTOTools({ user, event }));
|
||||
|
||||
// could also get these from the layout page
|
||||
// but getting them again for the most fresh data
|
||||
return {
|
||||
event,
|
||||
teams: findTeamsByEventId(event.id),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TournamentToolsAdminPage() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const submit = useSubmit();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [eventStarted, setEventStarted] = React.useState(
|
||||
Boolean(!data.event.isBeforeStart)
|
||||
);
|
||||
|
||||
function handleToggle(toggled: boolean) {
|
||||
setEventStarted(toggled);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("started", toggled ? "on" : "off");
|
||||
|
||||
submit(data, { method: "post" });
|
||||
}
|
||||
|
||||
function discordListContent() {
|
||||
return data.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => {
|
||||
const owner = team.members.find((user) => user.isOwner);
|
||||
invariant(owner);
|
||||
|
||||
return `${team.name} - ${discordFullName(owner)} - <@${
|
||||
owner.discordId
|
||||
}>`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack md half-width">
|
||||
<div>
|
||||
<label>{t("tournament:admin.eventStarted")}</label>
|
||||
<Toggle
|
||||
checked={eventStarted}
|
||||
setChecked={handleToggle}
|
||||
name="started"
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
{t("tournament:admin.eventStarted.explanation")}
|
||||
</FormMessage>
|
||||
</div>
|
||||
<div>
|
||||
<label>{t("tournament:admin.download")}</label>
|
||||
<div className="stack horizontal sm">
|
||||
<Button
|
||||
size="tiny"
|
||||
onClick={() =>
|
||||
handleDownload({
|
||||
filename: "discord.txt",
|
||||
content: discordListContent(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("tournament:admin.download.discord")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleDownload({
|
||||
content,
|
||||
filename,
|
||||
}: {
|
||||
content: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([content], {
|
||||
type: "text/plain",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = filename;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { type LoaderArgs, redirect } from "@remix-run/node";
|
||||
import { idFromParams } from "../tournament-utils";
|
||||
import { notFoundIfFalsy } from "~/utils/remix";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { toToolsMapsPage, toToolsRegisterPage } from "~/utils/urls";
|
||||
|
||||
export const loader = () => {
|
||||
return redirect("register");
|
||||
export const loader = ({ params }: LoaderArgs) => {
|
||||
const eventId = idFromParams(params);
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
|
||||
if (event.isBeforeStart) {
|
||||
throw redirect(toToolsRegisterPage(event.id));
|
||||
}
|
||||
|
||||
throw redirect(toToolsMapsPage(event.id));
|
||||
};
|
||||
|
||||
@@ -100,15 +100,7 @@ export default function JoinTeamPage() {
|
||||
case "VALID": {
|
||||
invariant(teamToJoin);
|
||||
|
||||
const teamName = teamToJoin.name;
|
||||
if (!teamName) {
|
||||
const owner = teamToJoin.members.find((member) => member.isOwner);
|
||||
invariant(owner);
|
||||
|
||||
return `Join ${owner.discordName}'s team for ${parentRouteData.event.name}?`;
|
||||
}
|
||||
|
||||
return `Join ${teamName} for ${parentRouteData.event.name}?`;
|
||||
return `Join ${teamToJoin.name} for ${parentRouteData.event.name}?`;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(validationStatus);
|
||||
@@ -144,7 +136,7 @@ function validateCanJoin({
|
||||
if (!teamToJoin) {
|
||||
return "NO_TEAM_MATCHING_CODE";
|
||||
}
|
||||
if (teamToJoin.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
|
||||
if (teamToJoin.members.length >= TOURNAMENT.TEAM_MAX_MEMBERS) {
|
||||
return "TEAM_FULL";
|
||||
}
|
||||
if (teamToJoin.members.some((member) => member.userId === userId)) {
|
||||
|
||||
364
app/features/tournament/routes/to.$id.maps.tsx
Normal file
364
app/features/tournament/routes/to.$id.maps.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
import { useActionData, useOutletContext } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import {
|
||||
createTournamentMapList,
|
||||
type BracketType,
|
||||
type TournamentMaplistInput,
|
||||
type TournamentMaplistSource,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
import mapsStyles from "~/styles/maps.css";
|
||||
import { type SendouRouteHandle } from "~/utils/remix";
|
||||
import { TOURNAMENT } from "../tournament-constants";
|
||||
import type { TournamentToolsLoaderData } from "./to.$id";
|
||||
import type { MapPoolMap } from "~/db/types";
|
||||
import { modesIncluded, resolveOwnedTeam } from "../tournament-utils";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { toToolsPage } from "~/utils/urls";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: mapsStyles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["tournament"],
|
||||
};
|
||||
|
||||
type TeamInState = {
|
||||
id: number;
|
||||
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[];
|
||||
};
|
||||
|
||||
export default function TournamentToolsMapsPage() {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const actionData = useActionData<{ failed?: boolean }>();
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
const [bestOf, setBestOf] = useSearchParamState<
|
||||
(typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]
|
||||
>({
|
||||
name: "bo",
|
||||
defaultValue: 3,
|
||||
revive: reviveBestOf,
|
||||
});
|
||||
const [teamOneId, setTeamOneId] = useSearchParamState({
|
||||
name: "team-one",
|
||||
defaultValue:
|
||||
resolveOwnedTeam({ teams: data.teams, userId: user?.id })?.id ??
|
||||
data.teams[0]!.id,
|
||||
revive: reviveTeam(data.teams.map((t) => t.id)),
|
||||
});
|
||||
const [teamTwoId, setTeamTwoId] = useSearchParamState({
|
||||
name: "team-two",
|
||||
defaultValue: data.teams[1]!.id,
|
||||
revive: reviveTeam(
|
||||
data.teams.map((t) => t.id),
|
||||
teamOneId
|
||||
),
|
||||
});
|
||||
const [roundNumber, setRoundNumber] = useSearchParamState({
|
||||
name: "round",
|
||||
defaultValue: 1,
|
||||
revive: reviveRound,
|
||||
});
|
||||
const [bracketType, setBracketType] = useSearchParamState<BracketType>({
|
||||
name: "bracket",
|
||||
defaultValue: "DE_WINNERS",
|
||||
revive: reviveBracketType,
|
||||
});
|
||||
|
||||
const teamOne = data.teams.find((t) => t.id === teamOneId) ?? {
|
||||
id: -1,
|
||||
mapPool: [],
|
||||
};
|
||||
const teamTwo = data.teams.find((t) => t.id === teamTwoId) ?? {
|
||||
id: -1,
|
||||
mapPool: [],
|
||||
};
|
||||
|
||||
if (!data.mapListGeneratorAvailable) {
|
||||
return <Redirect to={toToolsPage(data.event.id)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{actionData?.failed && (
|
||||
<Alert variation="ERROR" tiny>
|
||||
{t("tournament:generator.error")}
|
||||
</Alert>
|
||||
)}
|
||||
<RoundSelect
|
||||
roundNumber={roundNumber}
|
||||
bracketType={bracketType}
|
||||
handleChange={(roundNumber, bracketType) => {
|
||||
setRoundNumber(roundNumber);
|
||||
setBracketType(bracketType);
|
||||
}}
|
||||
/>
|
||||
<div className="tournament__teams-container">
|
||||
<TeamsSelect
|
||||
number={1}
|
||||
team={teamOne}
|
||||
otherTeam={teamTwo}
|
||||
setTeam={setTeamOneId}
|
||||
/>
|
||||
<TeamsSelect
|
||||
number={2}
|
||||
team={teamTwo}
|
||||
otherTeam={teamOne}
|
||||
setTeam={setTeamTwoId}
|
||||
/>
|
||||
</div>
|
||||
<BestOfRadios bestOf={bestOf} setBestOf={setBestOf} />
|
||||
<MapList
|
||||
teams={[
|
||||
{ ...teamOne, maps: new MapPool(teamOne.mapPool ?? []) },
|
||||
{ ...teamTwo, maps: new MapPool(teamTwo.mapPool ?? []) },
|
||||
]}
|
||||
bestOf={bestOf}
|
||||
bracketType={bracketType}
|
||||
roundNumber={roundNumber}
|
||||
modesIncluded={modesIncluded(data.event)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BRACKET_TYPES: Array<BracketType> = ["DE_WINNERS", "DE_LOSERS"];
|
||||
const AMOUNT_OF_ROUNDS = 12;
|
||||
|
||||
function reviveBestOf(value: string) {
|
||||
const parsed = Number(value);
|
||||
|
||||
return TOURNAMENT.AVAILABLE_BEST_OF.find((bo) => bo === parsed);
|
||||
}
|
||||
|
||||
function reviveBracketType(value: string) {
|
||||
return BRACKET_TYPES.find((bracketType) => bracketType === value);
|
||||
}
|
||||
|
||||
function reviveRound(value: string) {
|
||||
const parsed = Number(value);
|
||||
|
||||
return new Array(AMOUNT_OF_ROUNDS)
|
||||
.fill(null)
|
||||
.map((_, i) => i + 1)
|
||||
.find((val) => val === parsed);
|
||||
}
|
||||
|
||||
function reviveTeam(teamIds: number[], excludedTeamId?: number) {
|
||||
return function (value: string) {
|
||||
const parsed = Number(value);
|
||||
|
||||
return teamIds
|
||||
.filter((id) => id !== excludedTeamId)
|
||||
.find((id) => id === parsed);
|
||||
};
|
||||
}
|
||||
|
||||
function RoundSelect({
|
||||
roundNumber,
|
||||
bracketType,
|
||||
handleChange,
|
||||
}: {
|
||||
roundNumber: TournamentMaplistInput["roundNumber"];
|
||||
bracketType: TournamentMaplistInput["bracketType"];
|
||||
handleChange: (roundNumber: number, bracketType: BracketType) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
return (
|
||||
<div className="tournament__round-container tournament__select-container">
|
||||
<label htmlFor="round">{t("tournament:round.label")}</label>
|
||||
<select
|
||||
id="round"
|
||||
value={`${bracketType}-${roundNumber}`}
|
||||
onChange={(e) => {
|
||||
const [bracketType, roundNumber] = e.target.value.split("-") as [
|
||||
BracketType,
|
||||
string
|
||||
];
|
||||
handleChange(Number(roundNumber), bracketType);
|
||||
}}
|
||||
>
|
||||
{BRACKET_TYPES.flatMap((type) =>
|
||||
new Array(AMOUNT_OF_ROUNDS).fill(null).map((_, i) => {
|
||||
return (
|
||||
<option key={`${type}-${i}`} value={`${type}-${i + 1}`}>
|
||||
{t(`tournament:bracket.type.${type}`)} {i + 1}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamsSelect({
|
||||
number,
|
||||
team,
|
||||
otherTeam,
|
||||
setTeam,
|
||||
}: {
|
||||
number: number;
|
||||
team: { id: number };
|
||||
otherTeam: TeamInState;
|
||||
setTeam: (newTeamId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<div className="tournament__select-container">
|
||||
<label htmlFor="round">
|
||||
{t("tournament:team.label")} {number}
|
||||
</label>
|
||||
<select
|
||||
id="round"
|
||||
className="tournament__team-select"
|
||||
value={team.id}
|
||||
onChange={(e) => {
|
||||
setTeam(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
<option value={-1}>({t("tournament:team.unlisted")})</option>
|
||||
{data.teams
|
||||
.filter((t) => t.id !== otherTeam.id)
|
||||
.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BestOfRadios({
|
||||
bestOf,
|
||||
setBestOf,
|
||||
}: {
|
||||
bestOf: (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number];
|
||||
setBestOf: (bestOf: (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
return (
|
||||
<div className="tournament__bo-radios-container">
|
||||
{TOURNAMENT.AVAILABLE_BEST_OF.map((bestOfOption) => (
|
||||
<div key={bestOfOption}>
|
||||
<label htmlFor={String(bestOfOption)}>
|
||||
{t("tournament:bestOf.label.short")}
|
||||
{bestOfOption}
|
||||
</label>
|
||||
<input
|
||||
id={String(bestOfOption)}
|
||||
name="bestOf"
|
||||
type="radio"
|
||||
checked={bestOfOption === bestOf}
|
||||
onChange={() => setBestOf(bestOfOption)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapList(props: Omit<TournamentMaplistInput, "tiebreakerMaps">) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
let mapList: Array<TournamentMapListMap>;
|
||||
|
||||
try {
|
||||
mapList = createTournamentMapList({
|
||||
...props,
|
||||
tiebreakerMaps: new MapPool(data.tieBreakerMapPool),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Failed to create map list. Falling back to default maps.",
|
||||
e
|
||||
);
|
||||
|
||||
mapList = createTournamentMapList({
|
||||
...props,
|
||||
teams: [
|
||||
{
|
||||
id: -1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: -2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
tiebreakerMaps: new MapPool(data.tieBreakerMapPool),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tournament__map-list">
|
||||
{mapList.map(({ stageId, mode, source }, i) => {
|
||||
return (
|
||||
<React.Fragment key={`${stageId}-${mode}`}>
|
||||
<PickInfoText
|
||||
source={source}
|
||||
teamOneId={props.teams[0].id}
|
||||
teamTwoId={props.teams[1].id}
|
||||
/>
|
||||
<div key={stageId} className="tournament__stage-listed">
|
||||
{i + 1}) {mode} {t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickInfoText({
|
||||
source,
|
||||
teamOneId,
|
||||
teamTwoId,
|
||||
}: {
|
||||
source: TournamentMaplistSource;
|
||||
teamOneId: number;
|
||||
teamTwoId: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
const text = () => {
|
||||
if (source === teamOneId)
|
||||
return t("tournament:pickInfo.team", { number: 1 });
|
||||
if (source === teamTwoId)
|
||||
return t("tournament:pickInfo.team", { number: 2 });
|
||||
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
|
||||
if (source === "BOTH") return t("tournament:pickInfo.both");
|
||||
if (source === "DEFAULT") return t("tournament:pickInfo.default");
|
||||
|
||||
console.error(`Unknown source: ${String(source)}`);
|
||||
return "";
|
||||
};
|
||||
|
||||
const otherClassName = () => {
|
||||
if (source === teamOneId) return "team-1";
|
||||
if (source === teamTwoId) return "team-2";
|
||||
return typeof source === "string" ? source.toLocaleLowerCase() : source;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("tournament__pick-info", otherClassName())}>
|
||||
{text()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import type {
|
||||
ActionFunction,
|
||||
LoaderArgs,
|
||||
SerializeFrom,
|
||||
import {
|
||||
type ActionFunction,
|
||||
type LoaderArgs,
|
||||
type SerializeFrom,
|
||||
redirect,
|
||||
} from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData, useOutletContext } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
@@ -19,11 +18,16 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { getUserId, requireUserId } from "~/modules/auth/user.server";
|
||||
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type {
|
||||
ModeShort,
|
||||
RankedModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { stageIds } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import {
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
validate,
|
||||
type SendouRouteHandle,
|
||||
@@ -34,20 +38,34 @@ import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
CALENDAR_PAGE,
|
||||
LOG_IN_URL,
|
||||
SENDOU_INK_BASE_URL,
|
||||
modeImageUrl,
|
||||
navIconUrl,
|
||||
toToolsJoinPage,
|
||||
toToolsMapsPage,
|
||||
} from "~/utils/urls";
|
||||
import { createTeam } from "../queries/createTeam.server";
|
||||
import deleteTeamMember from "../queries/deleteTeamMember.server";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { findOwnTeam } from "../queries/findOwnTeam.server";
|
||||
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
|
||||
import { updateTeamInfo } from "../queries/updateTeamInfo.server";
|
||||
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
|
||||
import { FRIEND_CODE_REGEX_PATTERN, TOURNAMENT } from "../tournament-constants";
|
||||
import { TOURNAMENT } from "../tournament-constants";
|
||||
import { useSelectCounterpickMapPoolState } from "../tournament-hooks";
|
||||
import { registerSchema } from "../tournament-schemas.server";
|
||||
import { idFromParams, resolveOwnedTeam } from "../tournament-utils";
|
||||
import {
|
||||
isOneModeTournamentOf,
|
||||
HACKY_resolvePicture,
|
||||
idFromParams,
|
||||
resolveOwnedTeam,
|
||||
HACKY_resolveCheckInTime,
|
||||
} from "../tournament-utils";
|
||||
import type { TournamentToolsLoaderData } from "./to.$id";
|
||||
import { createTeam } from "../queries/createTeam.server";
|
||||
import { ClockIcon } from "~/components/icons/Clock";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
breadcrumb: () => ({
|
||||
@@ -62,6 +80,9 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestFormData({ request, schema: registerSchema });
|
||||
|
||||
const eventId = idFromParams(params);
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
|
||||
invariant(event.isBeforeStart);
|
||||
|
||||
const teams = findTeamsByEventId(eventId);
|
||||
const ownTeam = teams.find((team) =>
|
||||
@@ -69,27 +90,19 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
|
||||
switch (data._action) {
|
||||
case "CREATE_TEAM": {
|
||||
const userIsInTeam = teams.some((team) =>
|
||||
team.members.some((member) => member.userId === user.id)
|
||||
);
|
||||
|
||||
validate(!userIsInTeam);
|
||||
// TODO tournament: make sure tournament has not started
|
||||
|
||||
createTeam({ calendarEventId: idFromParams(params), ownerId: user.id });
|
||||
break;
|
||||
}
|
||||
case "UPDATE_TEAM_INFO": {
|
||||
validate(ownTeam);
|
||||
|
||||
// TODO tournament: make sure not changing name AND tournament is happening
|
||||
|
||||
updateTeamInfo({
|
||||
friendCode: data.friendCode,
|
||||
name: data.teamName,
|
||||
id: ownTeam.id,
|
||||
});
|
||||
case "UPSERT_TEAM": {
|
||||
if (ownTeam) {
|
||||
updateTeamInfo({
|
||||
name: data.teamName,
|
||||
id: ownTeam.id,
|
||||
});
|
||||
} else {
|
||||
createTeam({
|
||||
name: data.teamName,
|
||||
calendarEventId: eventId,
|
||||
ownerId: user.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "DELETE_TEAM_MEMBER": {
|
||||
@@ -97,17 +110,17 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
validate(ownTeam.members.some((member) => member.userId === data.userId));
|
||||
validate(data.userId !== user.id);
|
||||
|
||||
// TODO tournament: make sure tournament not happening
|
||||
|
||||
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
|
||||
break;
|
||||
}
|
||||
case "UPDATE_MAP_POOL": {
|
||||
const mapPool = new MapPool(data.mapPool);
|
||||
validate(ownTeam);
|
||||
validate(validateCounterPickMapPool(mapPool) === "VALID");
|
||||
validate(
|
||||
validateCounterPickMapPool(mapPool, isOneModeTournamentOf(event)) ===
|
||||
"VALID"
|
||||
);
|
||||
|
||||
// TODO tournament: make sure tournament not happening
|
||||
upsertCounterpickMaps({
|
||||
tournamentTeamId: ownTeam.id,
|
||||
mapPool: new MapPool(data.mapPool),
|
||||
@@ -123,8 +136,14 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const eventId = idFromParams(params);
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
|
||||
if (!event.isBeforeStart) {
|
||||
throw redirect(toToolsMapsPage(event.id));
|
||||
}
|
||||
|
||||
const user = await getUserId(request);
|
||||
if (!user) return null;
|
||||
|
||||
const ownTeam = findOwnTeam({
|
||||
@@ -139,6 +158,8 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function TournamentRegisterPage() {
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation();
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
|
||||
@@ -150,9 +171,8 @@ export default function TournamentRegisterPage() {
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<div className="tournament__logo-container">
|
||||
{/* TODO tournament: dynamic image */}
|
||||
<img
|
||||
src="https://abload.de/img/screenshot2022-12-15ap0ca1.png"
|
||||
src={HACKY_resolvePicture(parentRouteData.event)}
|
||||
alt=""
|
||||
className="tournament__logo"
|
||||
width={124}
|
||||
@@ -161,57 +181,120 @@ export default function TournamentRegisterPage() {
|
||||
<div>
|
||||
<div className="tournament__title">{parentRouteData.event.name}</div>
|
||||
<div className="tournament__by">
|
||||
by {discordFullName(parentRouteData.event.author)}
|
||||
<div className="stack horizontal xs items-center">
|
||||
<UserIcon className="tournament__info__icon" />{" "}
|
||||
{discordFullName(parentRouteData.event.author)}
|
||||
</div>
|
||||
<div className="stack horizontal xs items-center">
|
||||
<ClockIcon className="tournament__info__icon" />{" "}
|
||||
{isMounted
|
||||
? databaseTimestampToDate(
|
||||
parentRouteData.event.startTime
|
||||
).toLocaleString(i18n.language, {
|
||||
timeZoneName: "short",
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>{parentRouteData.event.description}</div>
|
||||
{teamRegularMemberOf ? (
|
||||
<Alert>You are in a team for this event</Alert>
|
||||
) : !data?.ownTeam ? (
|
||||
<Register />
|
||||
) : (
|
||||
<div>
|
||||
<EditTeam ownTeam={data.ownTeam} />
|
||||
</div>
|
||||
<RegistrationForms ownTeam={data?.ownTeam} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Register() {
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<form className="stack items-center" action={LOG_IN_URL} method="post">
|
||||
<Button size="big" type="submit">
|
||||
Log in to register
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PleaseLogIn() {
|
||||
return (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<SubmitButton size="big" state={fetcher.state} _action="CREATE_TEAM">
|
||||
Register now
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<form className="stack items-center" action={LOG_IN_URL} method="post">
|
||||
<Button size="big" type="submit">
|
||||
Log in to register
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTeam({
|
||||
function RegistrationForms({
|
||||
ownTeam,
|
||||
}: {
|
||||
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
|
||||
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return <PleaseLogIn />;
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<FillRoster ownTeam={ownTeam} />
|
||||
<RegisterToBracket />
|
||||
<TeamInfo ownTeam={ownTeam} />
|
||||
<CounterPickMapPoolPicker />
|
||||
{ownTeam ? (
|
||||
<>
|
||||
<FillRoster ownTeam={ownTeam} />
|
||||
<CounterPickMapPoolPicker />
|
||||
<RememberToCheckin />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterToBracket() {
|
||||
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">1. Register</h3>
|
||||
<section className="tournament__section text-center text-sm font-semi-bold">
|
||||
Register on{" "}
|
||||
<a
|
||||
href={parentRouteData.event.bracketUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{parentRouteData.event.bracketUrl}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamInfo({
|
||||
ownTeam,
|
||||
}: {
|
||||
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">2. Team info</h3>
|
||||
<section className="tournament__section">
|
||||
<fetcher.Form method="post" className="stack md items-center">
|
||||
<div className="tournament__section__input-container">
|
||||
<Label htmlFor="teamName">Team name</Label>
|
||||
<Input
|
||||
name="teamName"
|
||||
id="teamName"
|
||||
required
|
||||
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
|
||||
defaultValue={ownTeam?.name ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton _action="UPSERT_TEAM" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</section>
|
||||
<div className="tournament__section__warning">
|
||||
Use the same name as on the bracket
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,7 +309,10 @@ function FillRoster({
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const inviteLink = `https://sendou.ink/to/201/join?code=${ownTeam.inviteCode}`;
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${toToolsJoinPage({
|
||||
eventId: parentRouteData.event.id,
|
||||
inviteCode: ownTeam.inviteCode,
|
||||
})}`;
|
||||
|
||||
const { members: ownTeamMembers } =
|
||||
resolveOwnedTeam({
|
||||
@@ -240,12 +326,11 @@ function FillRoster({
|
||||
0
|
||||
);
|
||||
|
||||
// TODO tournament: + tournament has not started
|
||||
const showDeleteMemberSection = ownTeamMembers.length > 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">1. Fill roster</h3>
|
||||
<h3 className="tournament__section-header">3. Fill roster</h3>
|
||||
<section className="tournament__section stack lg items-center">
|
||||
<div className="stack md items-center">
|
||||
<div className="text-center text-sm">
|
||||
@@ -281,16 +366,9 @@ function FillRoster({
|
||||
<DeleteMember members={ownTeamMembers} />
|
||||
) : null}
|
||||
</section>
|
||||
<div
|
||||
className={clsx("tournament__section__warning", {
|
||||
"text-warning":
|
||||
ownTeamMembers.length < TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
|
||||
"text-success":
|
||||
ownTeamMembers.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
|
||||
})}
|
||||
>
|
||||
{TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL}-{TOURNAMENT.TEAM_MAX_MEMBERS}{" "}
|
||||
members needed to play
|
||||
<div className="tournament__section__warning">
|
||||
You can still play without submitting roster, but you might be seeded
|
||||
lower in the bracket.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -342,50 +420,6 @@ function DeleteMember({
|
||||
);
|
||||
}
|
||||
|
||||
function TeamInfo({
|
||||
ownTeam,
|
||||
}: {
|
||||
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">2. Team info</h3>
|
||||
<section className="tournament__section">
|
||||
<fetcher.Form method="post" className="stack md items-center">
|
||||
<div className="tournament__section__input-container">
|
||||
<Label htmlFor="teamName">Team name</Label>
|
||||
<Input
|
||||
name="teamName"
|
||||
id="teamName"
|
||||
required
|
||||
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
|
||||
defaultValue={ownTeam.name ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="tournament__section__input-container">
|
||||
<Label htmlFor="friendCode">Friend code</Label>
|
||||
<Input
|
||||
name="friendCode"
|
||||
id="friendCode"
|
||||
required
|
||||
placeholder="1209-3932-9498"
|
||||
pattern={String(FRIEND_CODE_REGEX_PATTERN)}
|
||||
defaultValue={ownTeam.friendCode ?? undefined}
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
The friend code your opponents should add during tournament
|
||||
</FormMessage>
|
||||
</div>
|
||||
<SubmitButton _action="UPDATE_TEAM_INFO" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterPickMapPoolPicker() {
|
||||
const { t } = useTranslation(["common", "game-misc"]);
|
||||
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
|
||||
@@ -409,7 +443,7 @@ function CounterPickMapPoolPicker() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">3. Pick map pool</h3>
|
||||
<h3 className="tournament__section-header">4. Pick map pool</h3>
|
||||
<section className="tournament__section">
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
@@ -420,58 +454,73 @@ function CounterPickMapPoolPicker() {
|
||||
name="mapPool"
|
||||
value={counterPickMapPool.serialized}
|
||||
/>
|
||||
{rankedModesShort.map((mode) => {
|
||||
const tiebreakerStageId = parentRouteData.tieBreakerMapPool.find(
|
||||
(stage) => stage.mode === mode
|
||||
)?.stageId;
|
||||
{rankedModesShort
|
||||
.filter(
|
||||
(mode) =>
|
||||
!isOneModeTournamentOf(parentRouteData.event) ||
|
||||
isOneModeTournamentOf(parentRouteData.event) === mode
|
||||
)
|
||||
.map((mode) => {
|
||||
const tiebreakerStageId = parentRouteData.tieBreakerMapPool.find(
|
||||
(stage) => stage.mode === mode
|
||||
)?.stageId;
|
||||
|
||||
return (
|
||||
<div key={mode} className="stack md">
|
||||
<div className="stack sm">
|
||||
<div className="stack horizontal sm items-center font-bold">
|
||||
<Image
|
||||
path={modeImageUrl(mode)}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
{t(`game-misc:MODE_LONG_${mode}`)}
|
||||
return (
|
||||
<div key={mode} className="stack md">
|
||||
<div className="stack sm">
|
||||
<div className="stack horizontal sm items-center font-bold">
|
||||
<Image
|
||||
path={modeImageUrl(mode)}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
{t(`game-misc:MODE_LONG_${mode}`)}
|
||||
</div>
|
||||
{typeof tiebreakerStageId === "number" ? (
|
||||
<div className="text-xs text-lighter">
|
||||
Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{typeof tiebreakerStageId === "number" ? (
|
||||
<div className="text-xs text-lighter">
|
||||
Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)}
|
||||
</div>
|
||||
) : null}
|
||||
{new Array(
|
||||
isOneModeTournamentOf(parentRouteData.event)
|
||||
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
||||
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
|
||||
)
|
||||
.fill(null)
|
||||
.map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="tournament__section__map-select-row"
|
||||
>
|
||||
Pick {i + 1}{" "}
|
||||
<select
|
||||
value={counterpickMaps[mode][i] ?? undefined}
|
||||
onChange={handleCounterpickMapPoolSelect(mode, i)}
|
||||
>
|
||||
<option value=""></option>
|
||||
{stageIds
|
||||
.filter((id) => id !== tiebreakerStageId)
|
||||
.map((stageId) => {
|
||||
return (
|
||||
<option key={stageId} value={stageId}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{new Array(2).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="tournament__section__map-select-row"
|
||||
>
|
||||
Pick {i + 1}{" "}
|
||||
<select
|
||||
value={counterpickMaps[mode][i] ?? undefined}
|
||||
onChange={handleCounterpickMapPoolSelect(mode, i)}
|
||||
>
|
||||
<option value=""></option>
|
||||
{stageIds
|
||||
.filter((id) => id !== tiebreakerStageId)
|
||||
.map((stageId) => {
|
||||
return (
|
||||
<option key={stageId} value={stageId}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{validateCounterPickMapPool(counterPickMapPool) === "VALID" ? (
|
||||
);
|
||||
})}
|
||||
{validateCounterPickMapPool(
|
||||
counterPickMapPool,
|
||||
isOneModeTournamentOf(parentRouteData.event)
|
||||
) === "VALID" ? (
|
||||
<SubmitButton
|
||||
_action="UPDATE_MAP_POOL"
|
||||
state={fetcher.state}
|
||||
@@ -481,11 +530,18 @@ function CounterPickMapPoolPicker() {
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<MapPoolValidationStatusMessage
|
||||
status={validateCounterPickMapPool(counterPickMapPool)}
|
||||
status={validateCounterPickMapPool(
|
||||
counterPickMapPool,
|
||||
isOneModeTournamentOf(parentRouteData.event)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</fetcher.Form>
|
||||
</section>
|
||||
<div className="tournament__section__warning">
|
||||
Picking a map pool is optional, but if you don't then you will be
|
||||
playing on your opponent's picks.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -516,14 +572,19 @@ type CounterPickValidationStatus =
|
||||
| "TOO_MUCH_STAGE_REPEAT";
|
||||
|
||||
function validateCounterPickMapPool(
|
||||
mapPool: MapPool
|
||||
mapPool: MapPool,
|
||||
isOneModeOnlyTournamentFor: ModeShort | null
|
||||
): CounterPickValidationStatus {
|
||||
const stageCounts = new Map<StageId, number>();
|
||||
for (const stageId of mapPool.stages) {
|
||||
if (!stageCounts.has(stageId)) {
|
||||
stageCounts.set(stageId, 0);
|
||||
}
|
||||
if (stageCounts.get(stageId)! === TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT) {
|
||||
|
||||
if (
|
||||
stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT ||
|
||||
(isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1)
|
||||
) {
|
||||
return "TOO_MUCH_STAGE_REPEAT";
|
||||
}
|
||||
|
||||
@@ -531,13 +592,54 @@ function validateCounterPickMapPool(
|
||||
}
|
||||
|
||||
if (
|
||||
mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
|
||||
!isOneModeOnlyTournamentFor &&
|
||||
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
|
||||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE)
|
||||
) {
|
||||
return "PICKING";
|
||||
}
|
||||
|
||||
if (
|
||||
isOneModeOnlyTournamentFor &&
|
||||
mapPool.parsed[isOneModeOnlyTournamentFor].length !==
|
||||
TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
||||
) {
|
||||
return "PICKING";
|
||||
}
|
||||
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function RememberToCheckin() {
|
||||
const { i18n } = useTranslation();
|
||||
const isMounted = useIsMounted();
|
||||
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
const checkInStartsString = isMounted
|
||||
? HACKY_resolveCheckInTime(parentRouteData.event).toLocaleTimeString(
|
||||
i18n.language,
|
||||
{
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
}
|
||||
)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="tournament__section-header">5. Check-in</h3>
|
||||
<section className="tournament__section text-center text-sm font-semi-bold">
|
||||
Check in starts at {checkInStartsString} here:{" "}
|
||||
<a
|
||||
href={parentRouteData.event.bracketUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{parentRouteData.event.bracketUrl}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function TournamentToolsTeamsPage() {
|
||||
const hasMapPool = () => {
|
||||
// before start empty array is returned if team has map list
|
||||
// after start empty array means team has no map list
|
||||
if (data.event.isBeforeStart) {
|
||||
if (!data.mapListGeneratorAvailable) {
|
||||
return Boolean(team.mapPool);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,15 +50,20 @@ export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const eventId = idFromParams(params);
|
||||
const event = notFoundIfFalsy(findByIdentifier(eventId));
|
||||
|
||||
const mapListGeneratorAvailable =
|
||||
canAdminCalendarTOTools({ user, event }) || !event.isBeforeStart;
|
||||
|
||||
return {
|
||||
// TODO tournament: remove isBeforeStart
|
||||
event: { ...event, isBeforeStart: true },
|
||||
event,
|
||||
tieBreakerMapPool:
|
||||
db.calendarEvents.findTieBreakerMapPoolByEventId(eventId),
|
||||
teams: censorMapPools(findTeamsByEventId(eventId)),
|
||||
mapListGeneratorAvailable,
|
||||
};
|
||||
|
||||
function censorMapPools(teams: FindTeamsByEventId): FindTeamsByEventId {
|
||||
if (mapListGeneratorAvailable) return teams;
|
||||
|
||||
return teams.map((team) =>
|
||||
team.members.some(
|
||||
(member) => member.userId === user?.id && member.isOwner
|
||||
@@ -85,7 +90,12 @@ export default function TournamentToolsLayout() {
|
||||
return (
|
||||
<Main>
|
||||
<SubNav>
|
||||
<SubNavLink to="register">Register</SubNavLink>
|
||||
{data.event.isBeforeStart ? (
|
||||
<SubNavLink to="register">{t("tournament:tabs.register")}</SubNavLink>
|
||||
) : null}
|
||||
{data.mapListGeneratorAvailable ? (
|
||||
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
|
||||
) : null}
|
||||
<SubNavLink to="teams">
|
||||
{t("tournament:tabs.teams", { count: data.teams.length })}
|
||||
</SubNavLink>
|
||||
|
||||
@@ -2,9 +2,8 @@ export const TOURNAMENT = {
|
||||
TEAM_NAME_MAX_LENGTH: 64,
|
||||
COUNTERPICK_MAPS_PER_MODE: 2,
|
||||
COUNTERPICK_MAX_STAGE_REPEAT: 2,
|
||||
COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6,
|
||||
TEAM_MIN_MEMBERS_FOR_FULL: 4,
|
||||
TEAM_MAX_MEMBERS: 6,
|
||||
AVAILABLE_BEST_OF: [3, 5, 7] as const,
|
||||
} as const;
|
||||
|
||||
export const FRIEND_CODE_REGEX_PATTERN = "^\\d{4}-\\d{4}-\\d{4}$";
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useOutletContext } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type { TournamentToolsLoaderData } from "./routes/to.$id";
|
||||
import { resolveOwnedTeam } from "./tournament-utils";
|
||||
import * as React from "react";
|
||||
import { TOURNAMENT } from "./tournament-constants";
|
||||
import { mapPickCountPerMode, resolveOwnedTeam } from "./tournament-utils";
|
||||
|
||||
export function useSelectCounterpickMapPoolState() {
|
||||
const user = useUser();
|
||||
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
const resolveInitialMapPool = (
|
||||
mode: RankedModeShort
|
||||
): [StageId | null, StageId | null] => {
|
||||
const resolveInitialMapPool = (mode: RankedModeShort) => {
|
||||
const ownMapPool =
|
||||
resolveOwnedTeam({
|
||||
teams: parentRouteData.teams,
|
||||
@@ -23,15 +20,15 @@ export function useSelectCounterpickMapPoolState() {
|
||||
.filter((pair) => pair.mode === mode)
|
||||
.map((pair) => pair.stageId);
|
||||
|
||||
if (filteredStages.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE) {
|
||||
return [null, null];
|
||||
if (filteredStages.length !== mapPickCountPerMode(parentRouteData.event)) {
|
||||
return new Array(mapPickCountPerMode(parentRouteData.event)).fill(null);
|
||||
}
|
||||
|
||||
return filteredStages as [StageId, StageId];
|
||||
};
|
||||
|
||||
const [counterpickMaps, setCounterpickMaps] = React.useState<
|
||||
Record<RankedModeShort, [StageId | null, StageId | null]>
|
||||
Record<RankedModeShort, (StageId | null)[]>
|
||||
>({
|
||||
SZ: resolveInitialMapPool("SZ"),
|
||||
TC: resolveInitialMapPool("TC"),
|
||||
@@ -47,14 +44,15 @@ export function useSelectCounterpickMapPoolState() {
|
||||
(e) => {
|
||||
setCounterpickMaps({
|
||||
...counterpickMaps,
|
||||
[mode]: [counterpickMaps[mode][0], counterpickMaps[mode][1]].map(
|
||||
(stageId, j) => {
|
||||
[mode]: new Array(mapPickCountPerMode(parentRouteData.event))
|
||||
.fill(null)
|
||||
.map((_, i) => counterpickMaps[mode][i])
|
||||
.map((stageId, j) => {
|
||||
if (i === j) {
|
||||
return e.target.value === "" ? null : Number(e.target.value);
|
||||
}
|
||||
return stageId;
|
||||
}
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { id } from "~/utils/zod";
|
||||
import { FRIEND_CODE_REGEX_PATTERN, TOURNAMENT } from "./tournament-constants";
|
||||
import { TOURNAMENT } from "./tournament-constants";
|
||||
|
||||
export const registerSchema = z.union([
|
||||
z.object({ _action: z.literal("CREATE_TEAM") }),
|
||||
z.object({
|
||||
_action: z.literal("UPDATE_TEAM_INFO"),
|
||||
_action: z.literal("UPSERT_TEAM"),
|
||||
teamName: z.string().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH),
|
||||
friendCode: z.string().regex(new RegExp(FRIEND_CODE_REGEX_PATTERN)),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("UPDATE_MAP_POOL"),
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { Params } from "@remix-run/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { User } from "~/db/types";
|
||||
import type { FindTeamsByEventId } from "./queries/findTeamsByEventId.server";
|
||||
import type { TournamentToolsLoaderData } from "./routes/to.$id";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort } from "~/modules/in-game-lists";
|
||||
import { TOURNAMENT } from "./tournament-constants";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
export function resolveOwnedTeam({
|
||||
teams,
|
||||
@@ -23,3 +28,42 @@ export function idFromParams(params: Params<string>) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function modesIncluded(
|
||||
event: TournamentToolsLoaderData["event"]
|
||||
): ModeShort[] {
|
||||
if (event.toToolsMode) return [event.toToolsMode];
|
||||
|
||||
return [...rankedModesShort];
|
||||
}
|
||||
|
||||
export function isOneModeTournamentOf(
|
||||
event: TournamentToolsLoaderData["event"]
|
||||
) {
|
||||
if (event.toToolsMode) return event.toToolsMode;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HACKY_resolvePicture(
|
||||
event: TournamentToolsLoaderData["event"]
|
||||
) {
|
||||
if (event.name.includes("In The Zone"))
|
||||
return "https://abload.de/img/screenshot2023-04-19a2bfv0.png";
|
||||
|
||||
return "https://abload.de/img/screenshot2022-12-15ap0ca1.png";
|
||||
}
|
||||
|
||||
// hacky because db query not taking in account possibility of many start times
|
||||
// AND always assumed check-in starts 1h before
|
||||
export function HACKY_resolveCheckInTime(
|
||||
event: TournamentToolsLoaderData["event"]
|
||||
) {
|
||||
return databaseTimestampToDate(event.startTime - 60 * 60);
|
||||
}
|
||||
|
||||
export function mapPickCountPerMode(event: TournamentToolsLoaderData["event"]) {
|
||||
return isOneModeTournamentOf(event)
|
||||
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
||||
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** xxx: remove all unused **/
|
||||
|
||||
.tournament__action-section {
|
||||
padding: var(--s-6);
|
||||
border-radius: var(--rounded);
|
||||
@@ -147,12 +145,9 @@
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
/** xxx: all new from here **/
|
||||
|
||||
.tournament__logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
@@ -161,11 +156,15 @@
|
||||
}
|
||||
|
||||
.tournament__title {
|
||||
color: var(--theme);
|
||||
font-size: var(--fonts-xl);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.tournament__info__icon {
|
||||
width: 18px;
|
||||
padding: var(--s-1) 0;
|
||||
}
|
||||
|
||||
.tournament__by {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
@@ -195,6 +194,7 @@
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
text-align: center;
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.tournament__section__map-select-row {
|
||||
|
||||
@@ -87,6 +87,10 @@ export class MapPool {
|
||||
);
|
||||
}
|
||||
|
||||
overlaps(other: MapPool): boolean {
|
||||
return this.stageModePairs.some((pair) => other.has(pair));
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return Object.values(this.parsed).every((stages) => stages.length === 0);
|
||||
}
|
||||
@@ -103,6 +107,15 @@ export class MapPool {
|
||||
return this.parsed;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
var index = -1;
|
||||
var data = this.stageModePairs;
|
||||
|
||||
return {
|
||||
next: () => ({ value: data[++index]!, done: !(index in data) }),
|
||||
};
|
||||
}
|
||||
|
||||
static EMPTY = new MapPool({
|
||||
SZ: [],
|
||||
TC: [],
|
||||
|
||||
@@ -7,6 +7,9 @@ import { MapPool } from "../map-pool-serializer";
|
||||
import type { TournamentMaplistInput } from "./types";
|
||||
|
||||
const TournamentMapListGenerator = suite("Tournament map list generator");
|
||||
const TournamentMapListGeneratorOneMode = suite(
|
||||
"Tournament map list generator (one mode)"
|
||||
);
|
||||
|
||||
const team1Picks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
@@ -50,6 +53,7 @@ const generateMaps = ({
|
||||
},
|
||||
],
|
||||
tiebreakerMaps = tiebreakerPicks,
|
||||
modesIncluded = [...rankedModesShort],
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return createTournamentMapList({
|
||||
bestOf,
|
||||
@@ -57,6 +61,7 @@ const generateMaps = ({
|
||||
roundNumber,
|
||||
teams,
|
||||
tiebreakerMaps,
|
||||
modesIncluded,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -348,4 +353,207 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const team1SZPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
{ mode: "SZ", stageId: 8 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
]);
|
||||
const team2SZPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 2 },
|
||||
{ mode: "SZ", stageId: 3 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "SZ", stageId: 10 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
]);
|
||||
const team2SZPicksNoOverlap = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 2 },
|
||||
{ mode: "SZ", stageId: 3 },
|
||||
{ mode: "SZ", stageId: 14 },
|
||||
{ mode: "SZ", stageId: 10 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
]);
|
||||
|
||||
TournamentMapListGeneratorOneMode(
|
||||
"Creates map list for one mode inferring from the team picks",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
assert.equal(mapList[i]!.mode, "SZ");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGeneratorOneMode(
|
||||
"Creates one mode map list from empty map lists",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
assert.equal(mapList[i]!.mode, "SZ");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGeneratorOneMode(
|
||||
"Creates all different maps from empty map lists",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const stages = new Set(mapList.map(({ stageId }) => stageId));
|
||||
assert.equal(stages.size, 5);
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGeneratorOneMode(
|
||||
"Tiebreaker is always from the maps of the teams when possible",
|
||||
() => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
roundNumber: i,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const last = mapList[mapList.length - 1];
|
||||
|
||||
assert.equal(last?.mode, "SZ");
|
||||
assert.equal(last?.stageId, 9);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGeneratorOneMode(
|
||||
"Tiebreaker is from neither team's pool if no overlap",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicksNoOverlap,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const last = mapList[mapList.length - 1];
|
||||
|
||||
assert.not.ok(
|
||||
team1SZPicks.stageModePairs.some(
|
||||
({ stageId }) => stageId === last?.stageId
|
||||
)
|
||||
);
|
||||
assert.not.ok(
|
||||
team2SZPicksNoOverlap.stageModePairs.some(
|
||||
({ stageId }) => stageId === last?.stageId
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGeneratorOneMode("Handles worst case duplication", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
bestOf: 7,
|
||||
});
|
||||
|
||||
for (const [i, stage] of mapList.entries()) {
|
||||
if (i === 6) {
|
||||
assert.equal(stage?.source, "TIEBREAKER");
|
||||
} else {
|
||||
assert.equal(stage?.source, "BOTH");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TournamentMapListGeneratorOneMode("Handles one team submitted no maps", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
for (const stage of mapList) {
|
||||
assert.equal(stage.source, 1);
|
||||
}
|
||||
});
|
||||
|
||||
TournamentMapListGenerator.run();
|
||||
TournamentMapListGeneratorOneMode.run();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ModeShort, StageId } from "../in-game-lists";
|
||||
import { type ModeShort, type StageId, stageIds } from "../in-game-lists";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type {
|
||||
TournamentMaplistInput,
|
||||
@@ -16,7 +16,7 @@ export function createTournamentMapList(
|
||||
input: TournamentMaplistInput
|
||||
): Array<TournamentMapListMap> {
|
||||
const { shuffle } = seededRandom(`${input.bracketType}-${input.roundNumber}`);
|
||||
const stages = shuffle(resolveStages());
|
||||
const stages = shuffle(resolveCommonStages());
|
||||
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
|
||||
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
|
||||
score: Infinity,
|
||||
@@ -24,6 +24,7 @@ export function createTournamentMapList(
|
||||
const usedStages = new Set<number>();
|
||||
|
||||
const backtrack = () => {
|
||||
invariant(mapList.length <= input.bestOf, "mapList.length > input.bestOf");
|
||||
const mapListScore = rateMapList();
|
||||
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
|
||||
bestMapList.maps = [...mapList];
|
||||
@@ -36,8 +37,10 @@ export function createTournamentMapList(
|
||||
}
|
||||
|
||||
const stageList =
|
||||
mapList.length < input.bestOf - 1
|
||||
? stages
|
||||
mapList.length < input.bestOf - 1 ||
|
||||
// in 1 mode only the tiebreaker is not a thing
|
||||
tournamentIsOneModeOnly()
|
||||
? resolveOneModeOnlyStages()
|
||||
: input.tiebreakerMaps.stageModePairs.map((p) => ({
|
||||
...p,
|
||||
score: 0,
|
||||
@@ -62,7 +65,7 @@ export function createTournamentMapList(
|
||||
|
||||
throw new Error("couldn't generate maplist");
|
||||
|
||||
function resolveStages() {
|
||||
function resolveCommonStages() {
|
||||
const sorted = input.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"];
|
||||
@@ -94,7 +97,7 @@ export function createTournamentMapList(
|
||||
) {
|
||||
// neither team submitted map, we go default
|
||||
result.push(
|
||||
...DEFAULT_MAP_POOL.stageModePairs.map((pair) => ({
|
||||
...getDefaultMapPool().map((pair) => ({
|
||||
...pair,
|
||||
score: 0,
|
||||
source: "DEFAULT" as const,
|
||||
@@ -116,22 +119,90 @@ export function createTournamentMapList(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOneModeOnlyStages() {
|
||||
if (utilizeOtherStageIdsInOneModeOnlyTournament()) {
|
||||
// no overlap so we need to use a random map for tiebreaker
|
||||
return shuffle([...stageIds])
|
||||
.filter(
|
||||
(stageId) =>
|
||||
!input.teams[0].maps.hasStage(stageId) &&
|
||||
!input.teams[1].maps.hasStage(stageId)
|
||||
)
|
||||
.map((stageId) => ({
|
||||
stageId,
|
||||
mode: input.modesIncluded[0]!,
|
||||
score: 0,
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
function utilizeOtherStageIdsInOneModeOnlyTournament() {
|
||||
if (mapList.length < input.bestOf - 1) return false;
|
||||
|
||||
if (
|
||||
input.teams.every((team) => !team.maps.isEmpty()) &&
|
||||
!input.teams[0].maps.overlaps(input.teams[1].maps)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamsMapsLeftNotPicked =
|
||||
[...input.teams[0].maps, ...input.teams[1].maps].filter(
|
||||
(stage) =>
|
||||
!mapList.some(
|
||||
(map) => map.stageId === stage.stageId && map.mode === stage.mode
|
||||
)
|
||||
).length > 0;
|
||||
if (!teamsMapsLeftNotPicked) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDefaultMapPool() {
|
||||
if (tournamentIsOneModeOnly()) {
|
||||
const mode = input.modesIncluded[0]!;
|
||||
|
||||
return stageIds.map((id) => ({ mode, stageId: id }));
|
||||
}
|
||||
|
||||
return DEFAULT_MAP_POOL.stageModePairs;
|
||||
}
|
||||
|
||||
type StageValidatorInput = Pick<
|
||||
ModeWithStageAndScore,
|
||||
"score" | "stageId" | "mode"
|
||||
>;
|
||||
|
||||
// adding rules here can achieve to things
|
||||
// 1) adjust what kind of map list is generated
|
||||
// 2) optimize the algorithm my eliminating subtrees from consideration
|
||||
function stageIsOk(stage: StageValidatorInput, index: number) {
|
||||
if (usedStages.has(index)) return false;
|
||||
if (mapListAlreadyFull()) return false;
|
||||
if (isEarlyModeRepeat(stage)) return false;
|
||||
if (isNotFollowingModePattern(stage)) return false;
|
||||
if (isMakingThingsUnfair(stage)) return false;
|
||||
if (isStageRepeatWithoutBreak(stage)) return false;
|
||||
if (isSecondPickBySameTeamInRow(stage)) return false;
|
||||
if (wouldPreventTiebreaker(stage)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function tournamentIsOneModeOnly() {
|
||||
return input.modesIncluded.length === 1;
|
||||
}
|
||||
|
||||
function mapListAlreadyFull() {
|
||||
return mapList.length === input.bestOf;
|
||||
}
|
||||
|
||||
function isEarlyModeRepeat(stage: StageValidatorInput) {
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
// all modes already appeared
|
||||
if (mapList.length >= 4) return false;
|
||||
|
||||
@@ -147,6 +218,8 @@ export function createTournamentMapList(
|
||||
}
|
||||
|
||||
function isNotFollowingModePattern(stage: StageValidatorInput) {
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
// not all modes appeared yet
|
||||
if (mapList.length < 4) return false;
|
||||
|
||||
@@ -191,6 +264,37 @@ export function createTournamentMapList(
|
||||
return lastStage.score === stage.score;
|
||||
}
|
||||
|
||||
function wouldPreventTiebreaker(stage: StageValidatorInput) {
|
||||
// tiebreaker always guaranteed if not one mode
|
||||
if (!tournamentIsOneModeOnly()) return false;
|
||||
|
||||
const commonMaps = input.teams[0].maps.stageModePairs.filter(
|
||||
({ stageId, mode }) =>
|
||||
input.teams[1].maps.stageModePairs.some(
|
||||
(pair) => pair.stageId === stageId && pair.mode === mode
|
||||
)
|
||||
);
|
||||
|
||||
const newMapList = [...mapList, stage];
|
||||
|
||||
const newCommonMaps = commonMaps.filter(
|
||||
({ stageId, mode }) =>
|
||||
!newMapList.some(
|
||||
(pair) => pair.stageId === stageId && pair.mode === mode
|
||||
)
|
||||
);
|
||||
|
||||
// there was at least one possible common map
|
||||
// to pick as tiebreaker but it (or they) got picked too early
|
||||
return (
|
||||
commonMaps.length > 0 &&
|
||||
// handles special case where both teams have the same maps in their pool
|
||||
commonMaps.length !== input.teams[0].maps.stageModePairs.length &&
|
||||
newCommonMaps.length === 0 &&
|
||||
newMapList.length !== input.bestOf
|
||||
);
|
||||
}
|
||||
|
||||
function rateMapList() {
|
||||
// not a full map list
|
||||
if (mapList.length !== input.bestOf) return;
|
||||
@@ -208,6 +312,36 @@ export function createTournamentMapList(
|
||||
appearedMaps.set(stage.stageId, timesAppeared + 1);
|
||||
}
|
||||
|
||||
if (!lastMapIsAGoodTieBreaker()) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function lastMapIsAGoodTieBreaker() {
|
||||
// guaranteed to be good if more than one mode
|
||||
if (!tournamentIsOneModeOnly()) return true;
|
||||
|
||||
// specifically made tiebreaker map is considered good
|
||||
const last = mapList[mapList.length - 1]!;
|
||||
if (last.source === "TIEBREAKER") return true;
|
||||
|
||||
// we can't have a map from pools of both teams if both didn't submit maps
|
||||
if (input.teams.some((team) => team.maps.stageModePairs.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tieBreakerMap = mapList[mapList.length - 1]!;
|
||||
|
||||
let appearanceCount = 0;
|
||||
|
||||
for (const team of input.teams) {
|
||||
for (const stage of team.maps.stages) {
|
||||
if (stage === tieBreakerMap.stageId) appearanceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return appearanceCount === 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModeWithStage } from "../in-game-lists";
|
||||
import type { ModeShort, ModeWithStage } from "../in-game-lists";
|
||||
import type { MapPool } from "../map-pool-serializer";
|
||||
|
||||
export type BracketType =
|
||||
@@ -23,6 +23,7 @@ export interface TournamentMaplistInput {
|
||||
}
|
||||
];
|
||||
tiebreakerMaps: MapPool;
|
||||
modesIncluded: ModeShort[];
|
||||
}
|
||||
|
||||
export type TournamentMaplistSource =
|
||||
|
||||
@@ -313,7 +313,7 @@ export function canEnableTOTools(user?: IsAdminUser) {
|
||||
}
|
||||
|
||||
interface CanAdminCalendarTOTools {
|
||||
user?: Pick<User, "id" | "discordId">;
|
||||
user?: Pick<User, "id">;
|
||||
event: Pick<CalendarEvent, "authorId">;
|
||||
}
|
||||
export function canAdminCalendarTOTools({
|
||||
|
||||
@@ -65,6 +65,8 @@ import {
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { Tags } from "./components/Tags";
|
||||
import type { RankedModeShort } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
|
||||
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
|
||||
|
||||
@@ -128,6 +130,7 @@ const newCalendarEventActionSchema = z.object({
|
||||
),
|
||||
pool: z.string().optional(),
|
||||
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
toToolsMode: z.enum(["ALL", "SZ", "TC", "RM", "CB"]).optional(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
@@ -154,8 +157,12 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
: data.tags,
|
||||
badges: data.badges ?? [],
|
||||
toToolsEnabled: canEnableTOTools(user) ? Number(data.toToolsEnabled) : 0,
|
||||
toToolsMode:
|
||||
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
|
||||
};
|
||||
|
||||
// TODO: messing with these and "one mode selection" can cause problems when teams
|
||||
// have already chosend maps for their pools
|
||||
const deserializedMaps = (() => {
|
||||
if (!data.pool) return;
|
||||
|
||||
@@ -592,13 +599,31 @@ function TOToolsAndMapPool() {
|
||||
const [checked, setChecked] = React.useState(
|
||||
Boolean(eventToEdit?.toToolsEnabled)
|
||||
);
|
||||
const [mode, setMode] = React.useState<"ALL" | RankedModeShort>("ALL");
|
||||
|
||||
return (
|
||||
<>
|
||||
{canEnableTOTools(user) && (
|
||||
<TOToolsEnabler checked={checked} setChecked={setChecked} />
|
||||
)}
|
||||
{checked ? <CounterPickMapPoolSection /> : <MapPoolSection />}
|
||||
{checked ? (
|
||||
<>
|
||||
<select
|
||||
className="calendar-new__select"
|
||||
onChange={(e) => setMode(e.target.value as RankedModeShort)}
|
||||
name="toToolsMode"
|
||||
>
|
||||
<option value="ALL">All modes</option>
|
||||
<option value="SZ">SZ only</option>
|
||||
<option value="TC">TC only</option>
|
||||
<option value="RM">RM only</option>
|
||||
<option value="CB">CB only</option>
|
||||
</select>
|
||||
{mode === "ALL" ? <CounterPickMapPoolSection /> : null}
|
||||
</>
|
||||
) : (
|
||||
<MapPoolSection />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ const staticAssetsUrl = ({
|
||||
}) =>
|
||||
`https://raw.githubusercontent.com/Sendouc/sendou-ink-assets/main/${folder}/${fileName}`;
|
||||
|
||||
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT = "https://sendou.nyc3.digitaloceanspaces.com";
|
||||
export const userSubmittedImage = (fileName: string) =>
|
||||
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;
|
||||
@@ -175,6 +177,16 @@ export const calendarEditPage = (eventId?: number) =>
|
||||
export const calendarReportWinnersPage = (eventId: number) =>
|
||||
`/calendar/${eventId}/report-winners`;
|
||||
export const toToolsPage = (eventId: number) => `/to/${eventId}`;
|
||||
export const toToolsRegisterPage = (eventId: number) =>
|
||||
`/to/${eventId}/register`;
|
||||
export const toToolsMapsPage = (eventId: number) => `/to/${eventId}/maps`;
|
||||
export const toToolsJoinPage = ({
|
||||
eventId,
|
||||
inviteCode,
|
||||
}: {
|
||||
eventId: number;
|
||||
inviteCode: string;
|
||||
}) => `/to/${eventId}/join?code=${inviteCode}`;
|
||||
|
||||
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
|
||||
`/maps${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
|
||||
12
migrations/024-adjust-tournament.js
Normal file
12
migrations/024-adjust-tournament.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports.up = function (db) {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "CalendarEvent" add "isBeforeStart" integer default 1`
|
||||
).run();
|
||||
db.prepare(
|
||||
/* sql */ `alter table "CalendarEvent" add "toToolsMode" text`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "TournamentTeam" drop column "friendCode"`
|
||||
).run();
|
||||
};
|
||||
@@ -2,13 +2,15 @@
|
||||
"tabs.info": "Info",
|
||||
"tabs.teams": "Teams ({{count}})",
|
||||
"tabs.admin": "Admin",
|
||||
"tabs.register": "Register",
|
||||
"tabs.maps": "Maps",
|
||||
|
||||
"pre.footerNote": "Note: you can change your map pool and roster as many times as you want before the tournament starts.",
|
||||
"pre.deleteTeam": "Delete team",
|
||||
|
||||
"preview": "Preview map list generator (admin only)",
|
||||
"pre.steps.register": "1. Register on",
|
||||
"pre.steps.register.summary": "Enter team name you register with",
|
||||
"pre.steps.register.summary": "Enter the team name you registered with",
|
||||
"pre.steps.mapPool": "2. Map pool",
|
||||
"pre.steps.mapPool.explanation": "You can play without selecting a map pool but then your opponent gets to decide what maps get played. Tie-breaker maps marked in blue.",
|
||||
"pre.steps.mapPool.summary": "Pick your team's maps",
|
||||
|
||||
@@ -22,6 +22,8 @@ module.exports = {
|
||||
);
|
||||
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
|
||||
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
|
||||
route("/to/:id/admin", "features/tournament/routes/to.$id.admin.tsx");
|
||||
route("/to/:id/maps", "features/tournament/routes/to.$id.maps.tsx");
|
||||
});
|
||||
|
||||
route("/privacy-policy", "features/info/routes/privacy-policy.tsx");
|
||||
|
||||
Reference in New Issue
Block a user