Invite only events + strict deadlines Closes #1709

This commit is contained in:
Kalle
2024-04-28 12:11:24 +03:00
parent 699afee091
commit bf3073c1fc
9 changed files with 105 additions and 73 deletions

View File

@@ -389,6 +389,8 @@ export interface TournamentSettings {
isRanked?: boolean;
autoCheckInAll?: boolean;
enableNoScreenToggle?: boolean;
deadlines?: "STRICT" | "DEFAULT";
isInvitational?: boolean;
/** Can teams add subs on their own while tournament is in progress? */
autonomousSubs?: boolean;
/** Timestamp (SQLite format) when reg closes, if missing then means closes at start time */

View File

@@ -408,6 +408,8 @@ type CreateArgs = Pick<
thirdPlaceMatch?: boolean;
autoCheckInAll?: boolean;
isRanked?: boolean;
isInvitational?: boolean;
deadlines: TournamentSettings["deadlines"];
enableNoScreenToggle?: boolean;
autonomousSubs?: boolean;
regClosesAt?: number;
@@ -435,6 +437,8 @@ export async function create(args: CreateArgs) {
teamsPerGroup: args.teamsPerGroup,
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
deadlines: args.deadlines,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
@@ -535,6 +539,8 @@ export async function update(args: UpdateArgs) {
teamsPerGroup: args.teamsPerGroup,
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
deadlines: args.deadlines,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,

View File

@@ -77,6 +77,8 @@ export const action: ActionFunction = async ({ request }) => {
teamsPerGroup: data.teamsPerGroup ?? undefined,
thirdPlaceMatch: data.thirdPlaceMatch ?? undefined,
isRanked: data.isRanked ?? undefined,
isInvitational: data.isInvitational ?? false,
deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const),
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
@@ -209,6 +211,8 @@ export const newCalendarEventActionSchema = z
z.boolean().nullish(),
),
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
//
// tournament format related fields
//

View File

@@ -170,6 +170,8 @@ function EventForm() {
<RankedToggle />
<EnableNoScreenToggle />
<AutonomousSubsToggle />
<InvitationalToggle />
<StrictDeadlinesToggle />
</>
) : null}
{isTournament ? <TournamentMapPickingStyleSelect /> : <MapPoolSection />}
@@ -640,6 +642,60 @@ function AutonomousSubsToggle() {
);
}
function InvitationalToggle() {
const baseEvent = useBaseEvent();
const [isInvitational, setIsInvitational] = React.useState(
baseEvent?.tournamentCtx?.settings.isInvitational ?? false,
);
const id = React.useId();
return (
<div>
<label htmlFor={id} className="w-max">
Invitational
</label>
<Toggle
name="isInvitational"
id={id}
tiny
checked={isInvitational}
setChecked={setIsInvitational}
/>
<FormMessage type="info">
No open registration or subs list. All teams must be added by the
organizer.
</FormMessage>
</div>
);
}
function StrictDeadlinesToggle() {
const baseEvent = useBaseEvent();
const [strictDeadlines, setStrictDeadlines] = React.useState(
baseEvent?.tournamentCtx?.settings.deadlines === "STRICT" ? true : false,
);
const id = React.useId();
return (
<div>
<label htmlFor={id} className="w-max">
Strict deadlines
</label>
<Toggle
name="strictDeadline"
id={id}
tiny
checked={strictDeadlines}
setChecked={setStrictDeadlines}
/>
<FormMessage type="info">
Strict deadlines has 5 minutes less for the target time of each round
(25min Bo3, 35min Bo5 compared to 30min Bo3, 40min Bo5 normal).
</FormMessage>
</div>
);
}
function RegClosesAtSelect() {
const baseEvent = useBaseEvent();
const [regClosesAt, setRegClosesAt] = React.useState<RegClosesAtOption>(

View File

@@ -13,10 +13,16 @@ const MINUTES = {
BO7: 50,
};
const minutesToPlay = (count: number) => {
if (count === 3) return MINUTES.BO3;
if (count === 5) return MINUTES.BO5;
if (count === 7) return MINUTES.BO7;
const STRICT_MINUTES = {
BO3: 25,
BO5: 35,
BO7: 45,
};
const minutesToPlay = (count: number, strict: boolean) => {
if (count === 3) return strict ? STRICT_MINUTES.BO3 : MINUTES.BO3;
if (count === 5) return strict ? STRICT_MINUTES.BO5 : MINUTES.BO5;
if (count === 7) return strict ? STRICT_MINUTES.BO7 : MINUTES.BO7;
logger.warn("Unknown best of count", { count });
return MINUTES.BO5;
@@ -73,7 +79,10 @@ export function useDeadline(roundId: number, bestOf: number) {
if (!dl) return null;
dl.setMinutes(dl.getMinutes() + minutesToPlay(bestOf));
dl.setMinutes(
dl.getMinutes() +
minutesToPlay(bestOf, tournament.ctx.settings.deadlines === "STRICT"),
);
return dl;
} catch (e) {

View File

@@ -9,10 +9,7 @@ import { assertUnreachable } from "~/utils/types";
import { isAdmin } from "~/permissions";
import { TOURNAMENT } from "~/features/tournament";
import type { TournamentData, TournamentDataTeam } from "./Tournament.server";
import {
HACKY_isInviteOnlyEvent,
HACKY_resolvePicture,
} from "~/features/tournament/tournament-utils";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists";
import {
@@ -547,10 +544,6 @@ export class Tournament {
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
}
get hasOpenRegistration() {
return !HACKY_isInviteOnlyEvent(this.ctx);
}
get hasStarted() {
return this.brackets.some((bracket) => !bracket.preview);
}
@@ -717,13 +710,12 @@ export class Tournament {
return true;
}
// TODO: get from settings
private isInvitational() {
return this.ctx.name.includes("Finale");
get isInvitational() {
return this.ctx.settings.isInvitational ?? false;
}
get subsFeatureEnabled() {
return !this.isInvitational();
return !this.isInvitational;
}
get canAddNewSubPost() {
@@ -738,7 +730,7 @@ export class Tournament {
}
get maxTeamMemberCount() {
const maxMembersBeforeStart = this.isInvitational()
const maxMembersBeforeStart = this.isInvitational
? 5
: TOURNAMENT.DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START;

View File

@@ -13,14 +13,10 @@ import { assertUnreachable } from "~/utils/types";
import { tournamentPage } from "~/utils/urls";
import { findByInviteCode } from "../queries/findTeamByInviteCode.server";
import { giveTrust } from "../queries/giveTrust.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { TOURNAMENT } from "../tournament-constants";
import { joinSchema } from "../tournament-schemas.server";
import {
tournamentIdFromParams,
tournamentTeamMaxSize,
} from "../tournament-utils";
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournamentFriendCode, useTournament } from "./to.$id";
import { FriendCodeInput } from "~/components/FriendCodeInput";
import * as UserRepository from "~/features/user-page/UserRepository.server";
@@ -60,8 +56,7 @@ export const action: ActionFunction = async ({ request, params }) => {
inviteCode,
teamToJoin,
userId: user.id,
tournamentHasStarted: tournament.hasStarted,
tournament: tournament.ctx,
maxTeamSize: tournament.maxTeamMemberCount,
}) === "VALID",
"Cannot join this team or invite code is invalid",
);
@@ -105,15 +100,13 @@ export const action: ActionFunction = async ({ request, params }) => {
throw redirect(tournamentPage(leanTeam.tournamentId));
};
export const loader = ({ request, params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
export const loader = ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const inviteCode = url.searchParams.get("code");
return {
teamId: inviteCode ? findByInviteCode(inviteCode)?.id : null,
inviteCode,
tournamentHasStarted: hasTournamentStarted(tournamentId),
};
};
@@ -128,11 +121,10 @@ export default function JoinTeamPage() {
const teamToJoin = data.teamId ? tournament.teamById(data.teamId) : undefined;
const captain = teamToJoin?.members.find((member) => member.isOwner);
const validationStatus = validateCanJoin({
tournament: tournament.ctx,
inviteCode: data.inviteCode,
teamToJoin,
userId: user?.id,
tournamentHasStarted: data.tournamentHasStarted,
maxTeamSize: tournament.maxTeamMemberCount,
});
const textPrompt = () => {
@@ -195,14 +187,12 @@ function validateCanJoin({
inviteCode,
teamToJoin,
userId,
tournamentHasStarted,
tournament,
maxTeamSize,
}: {
inviteCode?: string | null;
teamToJoin?: { members: { userId: number }[] };
userId?: number;
tournamentHasStarted: boolean;
tournament: { name: string };
maxTeamSize: number;
}) {
if (typeof inviteCode !== "string") {
return "MISSING_CODE";
@@ -216,10 +206,7 @@ function validateCanJoin({
if (!teamToJoin) {
return "NO_TEAM_MATCHING_CODE";
}
if (
teamToJoin.members.length >=
tournamentTeamMaxSize({ tournament, tournamentHasStarted })
) {
if (teamToJoin.members.length >= maxTeamSize) {
return "TEAM_FULL";
}
if (teamToJoin.members.some((member) => member.userId === userId)) {

View File

@@ -64,7 +64,6 @@ import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
import { TOURNAMENT } from "../tournament-constants";
import { registerSchema } from "../tournament-schemas.server";
import {
HACKY_isInviteOnlyEvent,
isOneModeTournamentOf,
tournamentIdFromParams,
} from "../tournament-utils";
@@ -118,7 +117,7 @@ export const action: ActionFunction = async ({ request, params }) => {
teamId: data.teamId ?? null,
});
} else {
validate(!HACKY_isInviteOnlyEvent(event), "Event is invite only");
validate(!tournament.isInvitational, "Event is invite only");
validate(
await UserRepository.currentFriendCodeByUserId(user.id),
"No friend code",
@@ -489,19 +488,19 @@ function RegistrationForms() {
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
if (!user && tournament.hasOpenRegistration) {
if (!user && !tournament.isInvitational) {
return <PleaseLogIn />;
}
const showRegistrationProgress = () => {
if (ownTeam) return true;
return tournament.hasOpenRegistration;
return !tournament.isInvitational;
};
const showRegisterNewTeam = () => {
if (ownTeam) return true;
if (!tournament.hasOpenRegistration) return false;
if (tournament.isInvitational) return false;
if (!tournament.registrationOpen) return false;
return !tournament.regularCheckInHasEnded;
@@ -516,7 +515,11 @@ function RegistrationForms() {
mapPool={data?.mapPool ?? undefined}
members={ownTeam?.members}
/>
) : null}
) : (
<Alert>
This tournament is invitational. Tournament organizer adds all teams.
</Alert>
)}
{showRegisterNewTeam() ? (
<>
<FriendCode />

View File

@@ -5,7 +5,6 @@ import type { ModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { tournamentLogoUrl } from "~/utils/urls";
import type { PlayedSet } from "./core/sets.server";
import { TOURNAMENT } from "./tournament-constants";
export function tournamentIdFromParams(params: Params<string>) {
const result = Number(params["id"]);
@@ -54,7 +53,7 @@ export function isOneModeTournamentOf(
export function HACKY_resolvePicture(event: { name: string }) {
const normalizedEventName = event.name.toLowerCase();
if (HACKY_isInviteOnlyEvent(event)) {
if (normalizedEventName.includes("sendouq")) {
return tournamentLogoUrl("sf");
}
@@ -166,7 +165,7 @@ const WHITE = "#fffcfc";
export function HACKY_resolveThemeColors(event: { name: string }) {
const normalizedEventName = event.name.toLowerCase();
if (HACKY_isInviteOnlyEvent(event)) {
if (normalizedEventName.includes("sendouq")) {
return { bg: "#1e1e1e", text: WHITE };
}
@@ -273,19 +272,6 @@ export function HACKY_resolveThemeColors(event: { name: string }) {
return { bg: "#3430ad", text: WHITE };
}
const HACKY_isSendouQSeasonFinale = (event: { name: string }) =>
event.name.includes("Finale");
export function HACKY_isInviteOnlyEvent(event: { name: string }) {
return HACKY_isSendouQSeasonFinale(event);
}
export function HACKY_maxRosterSizeBeforeStart(event: { name: string }) {
if (HACKY_isSendouQSeasonFinale(event)) return 5;
return TOURNAMENT.DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START;
}
export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
if (round.round === "grand_finals") return `bracket.grand_finals`;
if (round.round === "bracket_reset") {
@@ -295,16 +281,3 @@ export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
return `bracket.${round.type}` as const;
}
export function tournamentTeamMaxSize({
tournament,
tournamentHasStarted,
}: {
tournament: { name: string };
tournamentHasStarted: boolean;
}) {
// ensuring every team can add at least one sub while the tournament is ongoing
return (
HACKY_maxRosterSizeBeforeStart(tournament) + Number(tournamentHasStarted)
);
}