From 5e36b76ee85b9152f33f9cd36516bf4c61c57ff4 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Apr 2023 11:44:20 +0300 Subject: [PATCH] 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 --- app/components/icons/User.tsx | 16 + app/db/models/calendar/create.sql | 6 +- app/db/models/calendar/queries.server.ts | 1 + app/db/models/calendar/update.sql | 3 +- app/db/seed/index.ts | 39 +- app/db/types.ts | 6 +- .../tournament/queries/createTeam.server.ts | 9 +- .../queries/findByIdentifier.server.ts | 31 +- .../tournament/queries/findOwnTeam.server.ts | 3 +- .../queries/updateIsBeforeStart.server.ts | 20 + .../queries/updateTeamInfo.server.ts | 6 +- .../tournament/routes/to.$id.admin.tsx | 139 ++++++ .../tournament/routes/to.$id.index.tsx | 17 +- .../tournament/routes/to.$id.join.tsx | 12 +- .../tournament/routes/to.$id.maps.tsx | 364 ++++++++++++++ .../tournament/routes/to.$id.register.tsx | 464 +++++++++++------- .../tournament/routes/to.$id.teams.tsx | 2 +- app/features/tournament/routes/to.$id.tsx | 16 +- .../tournament/tournament-constants.ts | 3 +- app/features/tournament/tournament-hooks.ts | 24 +- .../tournament/tournament-schemas.server.ts | 6 +- app/features/tournament/tournament-utils.ts | 44 ++ app/features/tournament/tournament.css | 12 +- app/modules/map-pool-serializer/map-pool.ts | 13 + .../generation.test.ts | 208 ++++++++ .../tournament-map-list.ts | 146 +++++- .../tournament-map-list-generator/types.ts | 3 +- app/permissions.ts | 2 +- app/routes/calendar/new.tsx | 27 +- app/utils/urls.ts | 12 + migrations/024-adjust-tournament.js | 12 + public/locales/en/tournament.json | 4 +- remix.config.js | 2 + 33 files changed, 1403 insertions(+), 269 deletions(-) create mode 100644 app/components/icons/User.tsx create mode 100644 app/features/tournament/queries/updateIsBeforeStart.server.ts create mode 100644 app/features/tournament/routes/to.$id.admin.tsx create mode 100644 app/features/tournament/routes/to.$id.maps.tsx create mode 100644 migrations/024-adjust-tournament.js diff --git a/app/components/icons/User.tsx b/app/components/icons/User.tsx new file mode 100644 index 000000000..96a758eaf --- /dev/null +++ b/app/components/icons/User.tsx @@ -0,0 +1,16 @@ +export function UserIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/db/models/calendar/create.sql b/app/db/models/calendar/create.sql index 170a9b799..9e99f38e1 100644 --- a/app/db/models/calendar/create.sql +++ b/app/db/models/calendar/create.sql @@ -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 * diff --git a/app/db/models/calendar/queries.server.ts b/app/db/models/calendar/queries.server.ts index 64580f5fd..c9b5bf55b 100644 --- a/app/db/models/calendar/queries.server.ts +++ b/app/db/models/calendar/queries.server.ts @@ -66,6 +66,7 @@ export type CreateArgs = Pick< | "discordInviteCode" | "bracketUrl" | "toToolsEnabled" + | "toToolsMode" > & { startTimes: Array; badges: Array; diff --git a/app/db/models/calendar/update.sql b/app/db/models/calendar/update.sql index 770b09841..f183c8a48 100644 --- a/app/db/models/calendar/update.sql +++ b/app/db/models/calendar/update.sql @@ -6,6 +6,7 @@ set "description" = @description, "discordInviteCode" = @discordInviteCode, "bracketUrl" = @bracketUrl, - "toToolsEnabled" = @toToolsEnabled + "toToolsEnabled" = @toToolsEnabled, + "toToolsMode" = @toToolsMode where "id" = @eventId diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index ae2c02cde..77bff4f7c 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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> = {}; 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, }); diff --git a/app/db/types.ts b/app/db/types.ts index 4feebfe30..2981a2ffa 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -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; diff --git a/app/features/tournament/queries/createTeam.server.ts b/app/features/tournament/queries/createTeam.server.ts index b41a99b6c..169bdbea5 100644 --- a/app/features/tournament/queries/createTeam.server.ts +++ b/app/features/tournament/queries/createTeam.server.ts @@ -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; diff --git a/app/features/tournament/queries/findByIdentifier.server.ts b/app/features/tournament/queries/findByIdentifier.server.ts index 2e7efc88f..0e2e12e42 100644 --- a/app/features/tournament/queries/findByIdentifier.server.ts +++ b/app/features/tournament/queries/findByIdentifier.server.ts @@ -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) + Pick & + Pick) | null; export function findByIdentifier(identifier: string | number) { diff --git a/app/features/tournament/queries/findOwnTeam.server.ts b/app/features/tournament/queries/findOwnTeam.server.ts index c6c2d7f82..efdfb9a96 100644 --- a/app/features/tournament/queries/findOwnTeam.server.ts +++ b/app/features/tournament/queries/findOwnTeam.server.ts @@ -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({ diff --git a/app/features/tournament/queries/updateIsBeforeStart.server.ts b/app/features/tournament/queries/updateIsBeforeStart.server.ts new file mode 100644 index 000000000..951a11ea9 --- /dev/null +++ b/app/features/tournament/queries/updateIsBeforeStart.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/updateTeamInfo.server.ts b/app/features/tournament/queries/updateTeamInfo.server.ts index 301bd95ef..71d15770f 100644 --- a/app/features/tournament/queries/updateTeamInfo.server.ts +++ b/app/features/tournament/queries/updateTeamInfo.server.ts @@ -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, }); } diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx new file mode 100644 index 000000000..0b597429f --- /dev/null +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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(); + 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 ( +
+
+ + + + {t("tournament:admin.eventStarted.explanation")} + +
+
+ +
+ +
+
+
+ ); +} + +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(); +} diff --git a/app/features/tournament/routes/to.$id.index.tsx b/app/features/tournament/routes/to.$id.index.tsx index 2f1b91519..3a26204c3 100644 --- a/app/features/tournament/routes/to.$id.index.tsx +++ b/app/features/tournament/routes/to.$id.index.tsx @@ -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)); }; diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 326a4b89b..0f8d0f4b8 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -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)) { diff --git a/app/features/tournament/routes/to.$id.maps.tsx b/app/features/tournament/routes/to.$id.maps.tsx new file mode 100644 index 000000000..487793f84 --- /dev/null +++ b/app/features/tournament/routes/to.$id.maps.tsx @@ -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[]; +}; + +export default function TournamentToolsMapsPage() { + const user = useUser(); + const { t } = useTranslation(["tournament"]); + const actionData = useActionData<{ failed?: boolean }>(); + const data = useOutletContext(); + + 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({ + 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 ; + } + + return ( +
+ {actionData?.failed && ( + + {t("tournament:generator.error")} + + )} + { + setRoundNumber(roundNumber); + setBracketType(bracketType); + }} + /> +
+ + +
+ + +
+ ); +} + +const BRACKET_TYPES: Array = ["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 ( +
+ + +
+ ); +} + +function TeamsSelect({ + number, + team, + otherTeam, + setTeam, +}: { + number: number; + team: { id: number }; + otherTeam: TeamInState; + setTeam: (newTeamId: number) => void; +}) { + const { t } = useTranslation(["tournament"]); + const data = useOutletContext(); + + return ( +
+ + +
+ ); +} + +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 ( +
+ {TOURNAMENT.AVAILABLE_BEST_OF.map((bestOfOption) => ( +
+ + setBestOf(bestOfOption)} + /> +
+ ))} +
+ ); +} + +function MapList(props: Omit) { + const { t } = useTranslation(["game-misc"]); + const data = useOutletContext(); + + let mapList: Array; + + 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 ( +
+ {mapList.map(({ stageId, mode, source }, i) => { + return ( + + +
+ {i + 1}) {mode} {t(`game-misc:STAGE_${stageId}`)} +
+
+ ); + })} +
+ ); +} + +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 ( +
+ {text()} +
+ ); +} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 4efff538b..3035e07c8 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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(); const parentRouteData = useOutletContext(); @@ -150,9 +171,8 @@ export default function TournamentRegisterPage() { return (
- {/* TODO tournament: dynamic image */}
{parentRouteData.event.name}
- by {discordFullName(parentRouteData.event.author)} +
+ {" "} + {discordFullName(parentRouteData.event.author)} +
+
+ {" "} + {isMounted + ? databaseTimestampToDate( + parentRouteData.event.startTime + ).toLocaleString(i18n.language, { + timeZoneName: "short", + minute: "numeric", + hour: "numeric", + day: "numeric", + month: "numeric", + }) + : null} +
{parentRouteData.event.description}
{teamRegularMemberOf ? ( You are in a team for this event - ) : !data?.ownTeam ? ( - ) : ( -
- -
+ )} ); } -function Register() { - const user = useUser(); - const fetcher = useFetcher(); - - if (!user) { - return ( -
- -
- ); - } - +function PleaseLogIn() { return ( - - - Register now - - +
+ +
); } -function EditTeam({ +function RegistrationForms({ ownTeam, }: { - ownTeam: NonNullable>["ownTeam"]; + ownTeam?: NonNullable>["ownTeam"]; }) { + const user = useUser(); + + if (!user) return ; + return (
- + - + {ownTeam ? ( + <> + + + + + ) : null} +
+ ); +} + +function RegisterToBracket() { + const parentRouteData = useOutletContext(); + + return ( +
+

1. Register

+
+ Register on{" "} + + {parentRouteData.event.bracketUrl} + +
+
+ ); +} + +function TeamInfo({ + ownTeam, +}: { + ownTeam?: NonNullable>["ownTeam"]; +}) { + const fetcher = useFetcher(); + return ( +
+

2. Team info

+
+ +
+ + +
+ + Save + +
+
+
+ Use the same name as on the bracket +
); } @@ -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 (
-

1. Fill roster

+

3. Fill roster

@@ -281,16 +366,9 @@ function FillRoster({ ) : null}
-
= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, - })} - > - {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL}-{TOURNAMENT.TEAM_MAX_MEMBERS}{" "} - members needed to play +
+ You can still play without submitting roster, but you might be seeded + lower in the bracket.
); @@ -342,50 +420,6 @@ function DeleteMember({ ); } -function TeamInfo({ - ownTeam, -}: { - ownTeam: NonNullable>["ownTeam"]; -}) { - const fetcher = useFetcher(); - return ( -
-

2. Team info

-
- -
- - -
-
- - - - The friend code your opponents should add during tournament - -
- - Save - -
-
-
- ); -} - function CounterPickMapPoolPicker() { const { t } = useTranslation(["common", "game-misc"]); const parentRouteData = useOutletContext(); @@ -409,7 +443,7 @@ function CounterPickMapPoolPicker() { return (
-

3. Pick map pool

+

4. Pick map pool

- {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 ( -
-
-
- - {t(`game-misc:MODE_LONG_${mode}`)} + return ( +
+
+
+ + {t(`game-misc:MODE_LONG_${mode}`)} +
+ {typeof tiebreakerStageId === "number" ? ( +
+ Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)} +
+ ) : null}
- {typeof tiebreakerStageId === "number" ? ( -
- Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)} -
- ) : null} + {new Array( + isOneModeTournamentOf(parentRouteData.event) + ? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE + : TOURNAMENT.COUNTERPICK_MAPS_PER_MODE + ) + .fill(null) + .map((_, i) => { + return ( +
+ Pick {i + 1}{" "} + +
+ ); + })}
- {new Array(2).fill(null).map((_, i) => { - return ( -
- Pick {i + 1}{" "} - -
- ); - })} -
- ); - })} - {validateCounterPickMapPool(counterPickMapPool) === "VALID" ? ( + ); + })} + {validateCounterPickMapPool( + counterPickMapPool, + isOneModeTournamentOf(parentRouteData.event) + ) === "VALID" ? ( ) : ( )}
+
+ Picking a map pool is optional, but if you don't then you will be + playing on your opponent's picks. +
); } @@ -516,14 +572,19 @@ type CounterPickValidationStatus = | "TOO_MUCH_STAGE_REPEAT"; function validateCounterPickMapPool( - mapPool: MapPool + mapPool: MapPool, + isOneModeOnlyTournamentFor: ModeShort | null ): CounterPickValidationStatus { const stageCounts = new Map(); 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(); + + const checkInStartsString = isMounted + ? HACKY_resolveCheckInTime(parentRouteData.event).toLocaleTimeString( + i18n.language, + { + minute: "numeric", + hour: "numeric", + } + ) + : ""; + + return ( +
+

5. Check-in

+
+ Check in starts at {checkInStartsString} here:{" "} + + {parentRouteData.event.bracketUrl} + +
+
+ ); +} diff --git a/app/features/tournament/routes/to.$id.teams.tsx b/app/features/tournament/routes/to.$id.teams.tsx index 7c9f603eb..74f0523f6 100644 --- a/app/features/tournament/routes/to.$id.teams.tsx +++ b/app/features/tournament/routes/to.$id.teams.tsx @@ -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); } diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index aadb4129e..1bedfe665 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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 (
- Register + {data.event.isBeforeStart ? ( + {t("tournament:tabs.register")} + ) : null} + {data.mapListGeneratorAvailable ? ( + {t("tournament:tabs.maps")} + ) : null} {t("tournament:tabs.teams", { count: data.teams.length })} diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index cccf2fd93..130e1c879 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -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}$"; diff --git a/app/features/tournament/tournament-hooks.ts b/app/features/tournament/tournament-hooks.ts index c53e79b90..a8d789e50 100644 --- a/app/features/tournament/tournament-hooks.ts +++ b/app/features/tournament/tournament-hooks.ts @@ -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(); - 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 + Record >({ 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; - } - ), + }), }); }; diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 78b854bc7..c0ab6f408 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -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"), diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 8a30171fb..1e9d94e19 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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) { 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; +} diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index ae8ead502..8711d9b1d 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -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 { diff --git a/app/modules/map-pool-serializer/map-pool.ts b/app/modules/map-pool-serializer/map-pool.ts index 533d1ce82..4e1e0e5ca 100644 --- a/app/modules/map-pool-serializer/map-pool.ts +++ b/app/modules/map-pool-serializer/map-pool.ts @@ -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: [], diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index 142d65f5a..9e0df3bf6 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -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 = {}) => { 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(); diff --git a/app/modules/tournament-map-list-generator/tournament-map-list.ts b/app/modules/tournament-map-list-generator/tournament-map-list.ts index a315ca972..3926fd8b3 100644 --- a/app/modules/tournament-map-list-generator/tournament-map-list.ts +++ b/app/modules/tournament-map-list-generator/tournament-map-list.ts @@ -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 { const { shuffle } = seededRandom(`${input.bracketType}-${input.roundNumber}`); - const stages = shuffle(resolveStages()); + const stages = shuffle(resolveCommonStages()); const mapList: Array = []; const bestMapList: { maps?: Array; score: number } = { score: Infinity, @@ -24,6 +24,7 @@ export function createTournamentMapList( const usedStages = new Set(); 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; + } } diff --git a/app/modules/tournament-map-list-generator/types.ts b/app/modules/tournament-map-list-generator/types.ts index be5a1d35f..4a965af48 100644 --- a/app/modules/tournament-map-list-generator/types.ts +++ b/app/modules/tournament-map-list-generator/types.ts @@ -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 = diff --git a/app/permissions.ts b/app/permissions.ts index bc0cfb2bf..80eb23cb6 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -313,7 +313,7 @@ export function canEnableTOTools(user?: IsAdminUser) { } interface CanAdminCalendarTOTools { - user?: Pick; + user?: Pick; event: Pick; } export function canAdminCalendarTOTools({ diff --git a/app/routes/calendar/new.tsx b/app/routes/calendar/new.tsx index 73614a8fa..731e9dc3b 100644 --- a/app/routes/calendar/new.tsx +++ b/app/routes/calendar/new.tsx @@ -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) && ( )} - {checked ? : } + {checked ? ( + <> + + {mode === "ALL" ? : null} + + ) : ( + + )} ); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 34e3bc30e..2f26823c3 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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}` : ""}`; diff --git a/migrations/024-adjust-tournament.js b/migrations/024-adjust-tournament.js new file mode 100644 index 000000000..c11fbc1be --- /dev/null +++ b/migrations/024-adjust-tournament.js @@ -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(); +}; diff --git a/public/locales/en/tournament.json b/public/locales/en/tournament.json index 482d0cd66..6132cc310 100644 --- a/public/locales/en/tournament.json +++ b/public/locales/en/tournament.json @@ -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", diff --git a/remix.config.js b/remix.config.js index 3e4c54713..20901b91c 100644 --- a/remix.config.js +++ b/remix.config.js @@ -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");