mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
Invite only events + strict deadlines Closes #1709
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user