Free bracket progression (#1959)

* Initial

* Start implementing TournamentFormatSelector

* Progress

* Small progress

* Add source

* Progress

* Skeleton

* Progress

* Rename progression

* Can submit progression

* Fix potential issue with caching errors

* Settings

* isFinals/isUnderground

* Valid formats tests

* New bracket check in progress

* Perf optimization: simulate brackets only frontend

* Admin check in fix

* resolvesWinner logic

* SAME_PLACEMENT_TO_MULTIPLE_BRACKETS

* Structure work

* Edit bracket while tournament in progress initial

* Delayed check in to follow up bracket

* Progress validation

* NEGATIVE_PROGRESSION

* test first sources = null

* Different text when invitational

* More checks

* Validate changed are in preview

* Rename

* Translated errors

* Disbale submti if bracket progression is bad

* Adjust bracketIdx

* changedBracketProgressionFormat

* Progress

* Fix E2E tests

* Docs progress

* Fix state change

* Add docs
This commit is contained in:
Kalle 2024-11-10 12:07:43 +02:00 committed by GitHub
parent 7981c4ae99
commit c3444349b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3343 additions and 1374 deletions

View File

@ -891,7 +891,14 @@ function calendarEventWithToTools(
const settings: Tables["Tournament"]["settings"] =
event === "DEPTHS"
? {
bracketProgression: [{ type: "swiss", name: "Swiss" }],
bracketProgression: [
{
type: "swiss",
name: "Swiss",
requiresCheckIn: false,
settings: {},
},
],
enableNoScreenToggle: true,
isRanked: false,
swiss: {
@ -902,25 +909,38 @@ function calendarEventWithToTools(
: event === "SOS"
? {
bracketProgression: [
{ type: "round_robin", name: "Groups stage" },
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Great White",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1] }],
},
{
type: "single_elimination",
name: "Hammerhead",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [2] }],
},
{
type: "single_elimination",
name: "Mako",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [3] }],
},
{
type: "single_elimination",
name: "Lantern",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [4] }],
},
],
@ -929,15 +949,24 @@ function calendarEventWithToTools(
: event === "PP"
? {
bracketProgression: [
{ type: "round_robin", name: "Groups stage" },
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Final stage",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
{
type: "single_elimination",
name: "Underground bracket",
requiresCheckIn: true,
settings: {},
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
],
@ -945,17 +974,29 @@ function calendarEventWithToTools(
: event === "ITZ"
? {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
{
type: "double_elimination",
name: "Main bracket",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Underground bracket",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
},
],
}
: {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
{
type: "double_elimination",
name: "Main bracket",
requiresCheckIn: false,
settings: {},
},
],
};

View File

@ -7,6 +7,7 @@ import type {
} from "kysely";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { TEAM_MEMBER_ROLES } from "~/features/team";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ParticipantResult } from "~/modules/brackets-model";
import type {
Ability,
@ -397,24 +398,13 @@ type TournamentMapPickingStyle =
| "AUTO_RM"
| "AUTO_CB";
export type TournamentBracketProgression = {
type: TournamentStage["type"];
name: string;
/** Where do the teams come from? If missing then it means the source is the full registered teams list. */
sources?: {
/** Index of the bracket where the teams come from */
bracketIdx: number;
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. */
placements: number[];
}[];
}[];
export interface TournamentSettings {
bracketProgression: TournamentBracketProgression;
bracketProgression: Progression.ParsedBracket[];
/** @deprecated use bracketProgression instead */
teamsPerGroup?: number;
/** @deprecated use bracketProgression instead */
thirdPlaceMatch?: boolean;
isRanked?: boolean;
autoCheckInAll?: boolean;
enableNoScreenToggle?: boolean;
deadlines?: "STRICT" | "DEFAULT";
requireInGameNames?: boolean;
@ -423,6 +413,7 @@ export interface TournamentSettings {
autonomousSubs?: boolean;
/** Timestamp (SQLite format) when reg closes, if missing then means closes at start time */
regClosesAt?: number;
/** @deprecated use bracketProgression instead */
swiss?: {
groupCount: number;
roundCount: number;
@ -562,6 +553,24 @@ export interface TournamentRound {
maps: ColumnType<TournamentRoundMaps | null, string | null, string | null>;
}
export interface TournamentStageSettings {
// SE
thirdPlaceMatch?: boolean;
// RR
teamsPerGroup?: number;
// SWISS
groupCount?: number;
// SWISS
roundCount?: number;
}
export const TOURNAMENT_STAGE_TYPES = [
"single_elimination",
"double_elimination",
"round_robin",
"swiss",
] as const;
/** A stage is an intermediate phase in a tournament. In essence a bracket. */
export interface TournamentStage {
id: GeneratedAlways<number>;
@ -569,7 +578,7 @@ export interface TournamentStage {
number: number;
settings: string;
tournamentId: number;
type: "double_elimination" | "single_elimination" | "round_robin" | "swiss";
type: (typeof TOURNAMENT_STAGE_TYPES)[number];
// not Generated<> because SQLite doesn't allow altering tables to add columns with default values :(
createdAt: number | null;
}
@ -616,6 +625,8 @@ export interface TournamentTeamCheckIn {
/** Which bracket checked in for. If missing is check in for the whole event. */
bracketIdx: number | null;
tournamentTeamId: number;
/** Indicates that this bracket defaults to checked in and this team has been explicitly checked out from it */
isCheckOut: Generated<number>;
}
export interface TournamentTeamMember {

View File

@ -5,6 +5,7 @@ import { db } from "~/db/sql";
import type { DB, Tables, TournamentSettings } from "~/db/tables";
import type { CalendarEventTag } from "~/db/types";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { sumArray } from "~/utils/number";
@ -442,7 +443,6 @@ type CreateArgs = Pick<
minMembersPerTeam?: number;
teamsPerGroup?: number;
thirdPlaceMatch?: boolean;
autoCheckInAll?: boolean;
requireInGameNames?: boolean;
isRanked?: boolean;
isInvitational?: boolean;
@ -482,7 +482,6 @@ export async function create(args: CreateArgs) {
enableNoScreenToggle: args.enableNoScreenToggle,
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
requireInGameNames: args.requireInGameNames,
minMembersPerTeam: args.minMembersPerTeam,
swiss:
@ -559,7 +558,7 @@ export async function create(args: CreateArgs) {
: "calendarEventId",
});
return eventId;
return { eventId, tournamentId };
});
}
@ -632,7 +631,6 @@ export async function update(args: UpdateArgs) {
enableNoScreenToggle: args.enableNoScreenToggle,
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
requireInGameNames: args.requireInGameNames,
minMembersPerTeam: args.minMembersPerTeam,
swiss:
@ -644,14 +642,25 @@ export async function update(args: UpdateArgs) {
: undefined,
};
const existingBracketProgression = (
await trx
.selectFrom("Tournament")
.select("settings")
.where("id", "=", tournamentId)
.executeTakeFirstOrThrow()
).settings.bracketProgression;
const { mapPickingStyle: _mapPickingStyle } = await trx
.updateTable("Tournament")
.set({
settings: JSON.stringify(settings),
rules: args.rules,
// when tournament is updated clear the preparedMaps just in case the format changed
// in the future though we might want to be smarter with this i.e. only clear if the format really did change
preparedMaps: null,
preparedMaps: Progression.changedBracketProgressionFormat(
existingBracketProgression,
args.bracketProgression,
)
? null
: undefined,
})
.where("id", "=", tournamentId)
.returning("mapPickingStyle")

View File

@ -1,19 +1,18 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
import { TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import type { CalendarEventTag } from "~/db/types";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
FORMATS_SHORT,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { canEditCalendarEvent } from "~/permissions";
import {
@ -45,7 +44,6 @@ import {
canAddNewEvent,
regClosesAtDate,
} from "../calendar-utils";
import { formValuesToBracketProgression } from "../calendar-utils.server";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
@ -92,7 +90,7 @@ export const action: ActionFunction = async ({ request }) => {
: 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
bracketProgression: formValuesToBracketProgression(data),
bracketProgression: data.bracketProgression ?? null,
minMembersPerTeam: data.minMembersPerTeam ?? undefined,
teamsPerGroup: data.teamsPerGroup ?? undefined,
thirdPlaceMatch: data.thirdPlaceMatch ?? undefined,
@ -101,7 +99,6 @@ export const action: ActionFunction = async ({ request }) => {
deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const),
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
requireInGameNames: data.requireInGameNames ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
swissGroupCount: data.swissGroupCount ?? undefined,
swissRoundCount: data.swissRoundCount ?? undefined,
@ -166,14 +163,16 @@ export const action: ActionFunction = async ({ request }) => {
return "AUTO_ALL" as const;
};
const createdEventId = await CalendarRepository.create({
mapPoolMaps: deserializedMaps,
isFullTournament: data.toToolsEnabled,
mapPickingStyle: mapPickingStyle(),
...commonArgs,
});
const { eventId: createdEventId, tournamentId: createdTournamentId } =
await CalendarRepository.create({
mapPoolMaps: deserializedMaps,
isFullTournament: data.toToolsEnabled,
mapPickingStyle: mapPickingStyle(),
...commonArgs,
});
if (data.toToolsEnabled) {
if (createdTournamentId) {
clearTournamentDataCache(createdTournamentId);
ShowcaseTournaments.clearParticipationInfoMap();
ShowcaseTournaments.clearCachedTournaments();
}
@ -181,6 +180,38 @@ export const action: ActionFunction = async ({ request }) => {
throw redirect(calendarEventPage(createdEventId));
};
export const bracketProgressionSchema = z.preprocess(
safeJSONParse,
z
.array(
z.object({
type: z.enum(TOURNAMENT_STAGE_TYPES),
name: z.string().min(1).max(TOURNAMENT.BRACKET_NAME_MAX_LENGTH),
settings: z.object({
thirdPlaceMatch: z.boolean().optional(),
teamsPerGroup: z.number().int().optional(),
groupCount: z.number().int().optional(),
roundCount: z.number().int().optional(),
}),
requiresCheckIn: z.boolean(),
startTime: z.number().optional(),
sources: z
.array(
z.object({
bracketIdx: z.number(),
placements: z.array(z.number()),
}),
)
.optional(),
}),
)
.refine(
(progression) =>
Progression.bracketsToValidationError(progression) === null,
"Invalid bracket progression",
),
);
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
@ -255,14 +286,13 @@ export const newCalendarEventActionSchema = z
//
// tournament format related fields
//
format: z.enum(FORMATS_SHORT).nullish(),
bracketProgression: bracketProgressionSchema.nullish(),
minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(),
withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()),
thirdPlaceMatch: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
autoCheckInAll: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
teamsPerGroup: z.coerce
.number()
.min(TOURNAMENT.MIN_GROUP_SIZE)

View File

@ -1,94 +0,0 @@
import type { z } from "zod";
import type { TournamentSettings } from "~/db/tables";
import { BRACKET_NAMES } from "../tournament/tournament-constants";
import type { newCalendarEventActionSchema } from "./actions/calendar.new.server";
import { validateFollowUpBrackets } from "./calendar-utils";
export function formValuesToBracketProgression(
args: z.infer<typeof newCalendarEventActionSchema>,
) {
if (!args.format) return null;
const result: TournamentSettings["bracketProgression"] = [];
if (args.format === "DE") {
result.push({
name: BRACKET_NAMES.MAIN,
type: "double_elimination",
});
if (args.withUndergroundBracket) {
result.push({
name: BRACKET_NAMES.UNDERGROUND,
type: "single_elimination",
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
});
}
}
if (args.format === "SWISS") {
result.push({
name: BRACKET_NAMES.MAIN,
type: "swiss",
});
}
if (args.format === "SE") {
result.push({
name: BRACKET_NAMES.MAIN,
type: "single_elimination",
});
}
if (
args.format === "RR_TO_SE" &&
args.teamsPerGroup &&
args.followUpBrackets
) {
if (
validateFollowUpBrackets(
args.followUpBrackets,
args.format,
args.teamsPerGroup,
)
) {
return null;
}
result.push({
name: BRACKET_NAMES.GROUPS,
type: "round_robin",
});
for (const bracket of args.followUpBrackets) {
result.push({
name: bracket.name,
type: "single_elimination",
sources: [{ bracketIdx: 0, placements: bracket.placements }],
});
}
}
if (args.format === "SWISS_TO_SE" && args.followUpBrackets) {
if (validateFollowUpBrackets(args.followUpBrackets, args.format)) {
return null;
}
result.push({
name: BRACKET_NAMES.GROUPS,
type: "swiss",
});
for (const bracket of args.followUpBrackets) {
result.push({
name: bracket.name,
type: "single_elimination",
sources: [{ bracketIdx: 0, placements: bracket.placements }],
});
}
}
// should not happen
if (result.length === 0) return null;
return result;
}

View File

@ -1,30 +1,11 @@
import type { TournamentSettings } from "~/db/tables";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import { userDiscordIdIsAged } from "~/utils/users";
import type { TournamentFormatShort } from "../tournament/tournament-constants";
import type { RegClosesAtOption } from "./calendar-constants";
import type { FollowUpBracket } from "./calendar-types";
export const canAddNewEvent = (user: { discordId: string }) =>
userDiscordIdIsAged(user);
export function bracketProgressionToShortTournamentFormat(
bp: TournamentSettings["bracketProgression"],
): TournamentFormatShort {
if (bp.length === 1 && bp[0].type === "single_elimination") return "SE";
if (bp.some((b) => b.type === "double_elimination")) return "DE";
if (bp.length === 1 && bp[0].type === "swiss") return "SWISS";
if (
bp.some(({ type }) => type === "swiss") &&
bp.some(({ type }) => type === "single_elimination")
) {
return "SWISS_TO_SE";
}
return "RR_TO_SE";
}
export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28));
export const calendarEventMaxDate = () => {
const result = new Date();
@ -32,55 +13,6 @@ export const calendarEventMaxDate = () => {
return result;
};
export function validateFollowUpBrackets(
brackets: FollowUpBracket[],
format: TournamentFormatShort,
teamsPerGroup?: number,
) {
const placementsFound: number[] = [];
for (const bracket of brackets) {
for (const placement of bracket.placements) {
if (placementsFound.includes(placement)) {
return `Duplicate group placement for two different brackets: ${placement}`;
}
placementsFound.push(placement);
}
}
for (
let placement = 1;
placement <= Math.max(...placementsFound);
placement++
) {
if (!placementsFound.includes(placement)) {
return `No bracket for placement ${placement}`;
}
}
if (
format === "RR_TO_SE" &&
typeof teamsPerGroup === "number" &&
placementsFound.some((p) => p > teamsPerGroup)
) {
return "Placement higher than teams per group";
}
if (brackets.some((b) => !b.name)) {
return "Bracket name can't be empty";
}
if (brackets.some((b) => b.placements.length === 0)) {
return "Bracket must have at least one placement";
}
if (new Set(brackets.map((b) => b.name)).size !== brackets.length) {
return "Duplicate bracket name";
}
return null;
}
export function regClosesAtDate({
startTime,
closesAt,

View File

@ -0,0 +1,442 @@
import { nanoid } from "nanoid";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "~/components/Button";
import { DateInput } from "~/components/DateInput";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Toggle } from "~/components/Toggle";
import { PlusIcon } from "~/components/icons/Plus";
import { TOURNAMENT } from "~/features/tournament";
import * as Progression from "~/features/tournament-bracket/core/Progression";
const defaultBracket = (): Progression.InputBracket => ({
id: nanoid(),
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
});
export function BracketProgressionSelector({
initialBrackets,
isInvitationalTournament,
setErrored,
}: {
initialBrackets?: Progression.InputBracket[];
isInvitationalTournament: boolean;
setErrored: (errored: boolean) => void;
}) {
const [brackets, setBrackets] = React.useState<Progression.InputBracket[]>(
initialBrackets ?? [defaultBracket()],
);
const handleAddBracket = () => {
setBrackets([
...brackets,
{
...defaultBracket(),
id: nanoid(),
name: "",
sources: [
{
bracketId: brackets[0].id,
placements: "",
},
],
},
]);
};
const handleDeleteBracket = (idx: number) => {
const newBrackets = brackets.filter((_, i) => i !== idx);
const newBracketIds = new Set(newBrackets.map((b) => b.id));
const updatedBrackets = newBrackets.map((b) => ({
...b,
sources:
newBrackets.length === 1
? undefined
: b.sources?.map((source) => ({
...source,
bracketId: newBracketIds.has(source.bracketId)
? source.bracketId
: newBrackets[0].id,
})),
}));
setBrackets(updatedBrackets);
};
const validated = Progression.validatedBrackets(brackets);
React.useEffect(() => {
if (Progression.isError(validated)) {
setErrored(true);
} else {
setErrored(false);
}
}, [validated, setErrored]);
return (
<div className="stack lg items-start">
{Progression.isBrackets(validated) ? (
<input
type="hidden"
name="bracketProgression"
value={JSON.stringify(validated)}
/>
) : null}
<div className="stack lg">
{brackets.map((bracket, i) => (
<TournamentFormatBracketSelector
key={bracket.id}
bracket={bracket}
brackets={brackets}
onChange={(newBracket) => {
const newBrackets = [...brackets];
newBrackets[i] = newBracket;
setBrackets(newBrackets);
}}
onDelete={
i !== 0 && !bracket.disabled
? () => handleDeleteBracket(i)
: undefined
}
count={i + 1}
isInvitationalTournament={isInvitationalTournament}
/>
))}
</div>
<Button
icon={<PlusIcon />}
size="tiny"
variant="outlined"
onClick={handleAddBracket}
disabled={brackets.length >= TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT}
>
Add bracket
</Button>
{Progression.isError(validated) ? (
<ErrorMessage error={validated} />
) : null}
</div>
);
}
function TournamentFormatBracketSelector({
bracket,
brackets,
onChange,
onDelete,
count,
isInvitationalTournament,
}: {
bracket: Progression.InputBracket;
brackets: Progression.InputBracket[];
onChange: (newBracket: Progression.InputBracket) => void;
onDelete?: () => void;
count: number;
isInvitationalTournament: boolean;
}) {
const id = React.useId();
const createId = (name: string) => {
return `${id}-${name}`;
};
const isFirstBracket = count === 1;
const updateBracket = (newProps: Partial<Progression.InputBracket>) => {
onChange({ ...bracket, ...newProps });
};
return (
<div className="stack horizontal md items-center">
<div>
<div className="format-selector__count">Bracket #{count}</div>
{onDelete ? (
<Button
size="tiny"
variant="minimal-destructive"
onClick={onDelete}
className="mx-auto"
testId="delete-bracket-button"
>
Delete
</Button>
) : null}
</div>
<div className="format-selector__divider" />
<div className="stack md items-start">
<div>
<Label htmlFor={createId("name")}>Bracket's name</Label>
<Input
id={createId("name")}
value={bracket.name}
onChange={(e) => updateBracket({ name: e.target.value })}
maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH}
readOnly={bracket.disabled}
/>
</div>
{!isFirstBracket ? (
<div>
<Label htmlFor={createId("startTime")}>Start time</Label>
<DateInput
id={createId("startTime")}
defaultValue={bracket.startTime ?? undefined}
onChange={(newDate) =>
updateBracket({ startTime: newDate ?? undefined })
}
readOnly={bracket.disabled}
/>
<FormMessage type="info">
If missing, bracket can be started when the previous brackets have
finished
</FormMessage>
</div>
) : null}
{!isFirstBracket ? (
<div>
<Label htmlFor={createId("checkIn")}>Check-in required</Label>
<Toggle
checked={bracket.requiresCheckIn}
setChecked={(checked) =>
updateBracket({ requiresCheckIn: checked })
}
disabled={bracket.disabled}
/>
<FormMessage type="info">
Check-in starts 1 hour before start time or right after the
previous bracket finishes if no start time is set
</FormMessage>
</div>
) : null}
<div>
<Label htmlFor={createId("format")}>Format</Label>
<select
value={bracket.type}
onChange={(e) =>
updateBracket({
type: e.target.value as Progression.InputBracket["type"],
})
}
className="w-max"
name="format"
id={createId("format")}
disabled={bracket.disabled}
>
<option value="single_elimination">Single-elimination</option>
<option value="double_elimination">Double-elimination</option>
<option value="round_robin">Round robin</option>
<option value="swiss">Swiss</option>
</select>
</div>
{bracket.type === "single_elimination" ? (
<div>
<Label htmlFor={createId("thirdPlaceMatch")}>
Third place match
</Label>
<Toggle
checked={Boolean(bracket.settings.thirdPlaceMatch)}
setChecked={(checked) =>
updateBracket({
settings: { ...bracket.settings, thirdPlaceMatch: checked },
})
}
disabled={bracket.disabled}
/>
</div>
) : null}
{bracket.type === "round_robin" ? (
<div>
<Label htmlFor="teamsPerGroup">Teams per group</Label>
<select
value={bracket.settings.teamsPerGroup ?? 4}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
teamsPerGroup: Number(e.target.value),
},
})
}
className="w-max"
name="teamsPerGroup"
id="teamsPerGroup"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissGroupCount">Groups count</Label>
<select
value={bracket.settings.groupCount ?? 1}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
groupCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
disabled={bracket.disabled}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissRoundCount">Round count</Label>
<select
value={bracket.settings.roundCount ?? 5}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
roundCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
) : null}
<div>
<div className="stack horizontal sm">
<Label htmlFor={createId("source")}>Source</Label>{" "}
</div>
{isFirstBracket ? (
<FormMessage type="info">
{isInvitationalTournament ? (
<>Participants added by the organizer</>
) : (
<>Participants join from sign-up</>
)}
</FormMessage>
) : (
<SourcesSelector
brackets={brackets.filter(
(bracket2) => bracket.id !== bracket2.id && bracket2.name,
)}
source={bracket.sources?.[0] ?? null}
onChange={(source) => updateBracket({ sources: [source] })}
/>
)}
</div>
</div>
</div>
);
}
function SourcesSelector({
brackets,
source,
onChange,
}: {
brackets: Progression.InputBracket[];
source: Progression.EditableSource | null;
onChange: (sources: Progression.EditableSource) => void;
}) {
const id = React.useId();
const createId = (label: string) => {
return `${id}-${label}`;
};
return (
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
</div>
);
}
function ErrorMessage({ error }: { error: Progression.ValidationError }) {
const { t } = useTranslation(["tournament"]);
const bracketIdxsArr = (() => {
if (typeof (error as { bracketIdx: number }).bracketIdx === "number") {
return [(error as { bracketIdx: number }).bracketIdx];
}
if ((error as { bracketIdxs: number[] }).bracketIdxs) {
return (error as { bracketIdxs: number[] }).bracketIdxs;
}
return null;
})();
return (
<FormMessage type="error">
Problems with the bracket progression
{bracketIdxsArr ? (
<> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")})</>
) : null}
: {t(`tournament:progression.error.${error.type}`)}
</FormMessage>
);
}

View File

@ -15,7 +15,6 @@ import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { Placement } from "~/components/Placement";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
@ -25,13 +24,10 @@ import type { Tables } from "~/db/tables";
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import {
BRACKET_NAMES,
type TournamentFormatShort,
} from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { RankedModeShort } from "~/modules/in-game-lists";
import { isDefined, nullFilledArray } from "~/utils/arrays";
import { isDefined } from "~/utils/arrays";
import {
databaseTimestampToDate,
getDateAtNextFullHour,
@ -40,22 +36,20 @@ import {
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { pathnameFromPotentialURL } from "~/utils/strings";
import { userSubmittedImage } from "~/utils/urls";
import { CREATING_TOURNAMENT_DOC_LINK, userSubmittedImage } from "~/utils/urls";
import {
CALENDAR_EVENT,
REG_CLOSES_AT_OPTIONS,
type RegClosesAtOption,
} from "../calendar-constants";
import type { FollowUpBracket } from "../calendar-types";
import {
bracketProgressionToShortTournamentFormat,
calendarEventMaxDate,
calendarEventMinDate,
datesToRegClosesAt,
regClosesAtToDisplayName,
validateFollowUpBrackets,
} from "../calendar-utils";
import { canAddNewEvent } from "../calendar-utils";
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
import { Tags } from "../components/Tags";
import "~/styles/calendar-new.css";
@ -74,7 +68,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["calendar", "game-misc"],
i18n: ["calendar", "game-misc", "tournament"],
};
const useBaseEvent = () => {
@ -112,6 +106,20 @@ export default function CalendarNewEventPage() {
return (
<Main className="calendar-new__container">
<div className="stack md">
<div className="stack horizontal md items-center">
<h1 className="text-lg">
{data.isAddingTournament ? "New tournament" : "New calendar event"}
</h1>
<a
href={CREATING_TOURNAMENT_DOC_LINK}
className="text-lg text-bold"
title="Documentation about creating tournaments"
target="_blank"
rel="noopener noreferrer"
>
?
</a>
</div>
{data.isAddingTournament ? <TemplateTournamentForm /> : null}
<EventForm key={baseEvent?.eventId} />
</div>
@ -160,10 +168,15 @@ function EventForm() {
const fetcher = useFetcher();
const { t } = useTranslation();
const { eventToEdit, eventToCopy } = useLoaderData<typeof loader>();
const ref = React.useRef<HTMLFormElement>(null);
const [avatarImg, setAvatarImg] = React.useState<File | null>(null);
const baseEvent = useBaseEvent();
const [isInvitational, setIsInvitational] = React.useState(
baseEvent?.tournament?.ctx.settings.isInvitational ?? false,
);
const data = useLoaderData<typeof loader>();
const [bracketProgressionErrored, setBracketProgressionErrored] =
React.useState(false);
const handleSubmit = () => {
const formData = new FormData(ref.current!);
@ -185,6 +198,7 @@ function EventForm() {
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
if (bracketProgressionErrored) return true;
return false;
};
@ -219,12 +233,16 @@ function EventForm() {
{data.isAddingTournament ? (
<>
<Divider>Tournament settings</Divider>
<MemberCountSelect />
<RegClosesAtSelect />
<RankedToggle />
<EnableNoScreenToggle />
<AutonomousSubsToggle />
<RequireIGNToggle />
<InvitationalToggle />
<InvitationalToggle
isInvitational={isInvitational}
setIsInvitational={setIsInvitational}
/>
<StrictDeadlinesToggle />
</>
) : null}
@ -233,7 +251,23 @@ function EventForm() {
) : (
<MapPoolSection />
)}
{data.isAddingTournament ? <TournamentFormatSelector /> : null}
{data.isAddingTournament ? (
<div className="stack md w-full">
<Divider>Tournament format</Divider>
<BracketProgressionSelector
initialBrackets={
data.eventToEdit?.tournament?.ctx.settings.bracketProgression
? Progression.validatedBracketsToInputFormat(
data.eventToEdit?.tournament?.ctx.settings
.bracketProgression,
)
: undefined
}
isInvitationalTournament={isInvitational}
setErrored={setBracketProgressionErrored}
/>
</div>
) : null}
<Button
className="mt-4"
onClick={handleSubmit}
@ -865,11 +899,13 @@ function RequireIGNToggle() {
);
}
function InvitationalToggle() {
const baseEvent = useBaseEvent();
const [isInvitational, setIsInvitational] = React.useState(
baseEvent?.tournament?.ctx.settings.isInvitational ?? false,
);
function InvitationalToggle({
isInvitational,
setIsInvitational,
}: {
isInvitational: boolean;
setIsInvitational: (value: boolean) => void;
}) {
const id = React.useId();
return (
@ -997,7 +1033,8 @@ function TournamentMapPickingStyleSelect() {
}
return (
<>
<div className="stack md w-full items-start">
<Divider>Tournament maps</Divider>
<div>
<label htmlFor={id}>Map picking style</label>
<select
@ -1030,7 +1067,7 @@ function TournamentMapPickingStyleSelect() {
/>
</>
) : null}
</>
</div>
);
}
@ -1158,169 +1195,6 @@ function MapPoolValidationStatusMessage({
);
}
function TournamentFormatSelector() {
const baseEvent = useBaseEvent();
const [format, setFormat] = React.useState<TournamentFormatShort>(
baseEvent?.tournament?.ctx.settings.bracketProgression
? bracketProgressionToShortTournamentFormat(
baseEvent.tournament.ctx.settings.bracketProgression,
)
: "DE",
);
const [withUndergroundBracket, setWithUndergroundBracket] = React.useState(
baseEvent?.tournament
? baseEvent.tournament.ctx.settings.bracketProgression.some(
(b) => b.name === BRACKET_NAMES.UNDERGROUND,
)
: false,
);
const [thirdPlaceMatch, setThirdPlaceMatch] = React.useState(
baseEvent?.tournament?.ctx.settings.thirdPlaceMatch ?? true,
);
const [teamsPerGroup, setTeamsPerGroup] = React.useState(
baseEvent?.tournament?.ctx.settings.teamsPerGroup ?? 4,
);
const [swissGroupCount, setSwissGroupCount] = React.useState(
baseEvent?.tournament?.ctx.settings.swiss?.groupCount ?? 1,
);
const [swissRoundCount, setSwissRoundCount] = React.useState(
baseEvent?.tournament?.ctx.settings.swiss?.roundCount ?? 5,
);
return (
<div className="stack md w-full">
<Divider>Tournament format</Divider>
<MemberCountSelect />
<div>
<Label htmlFor="format">Format</Label>
<select
value={format}
onChange={(e) => setFormat(e.target.value as TournamentFormatShort)}
className="w-max"
name="format"
id="format"
>
<option value="SE">Single-elimination</option>
<option value="DE">Double-elimination</option>
<option value="RR_TO_SE">
Round robin -{">"} Single-elimination
</option>
<option value="SWISS">Swiss</option>
<option value="SWISS_TO_SE">Swiss -{">"} Single-elimination</option>
</select>
</div>
{format === "DE" ? (
<div>
<Label htmlFor="withUndergroundBracket">
With underground bracket
</Label>
<Toggle
checked={withUndergroundBracket}
setChecked={setWithUndergroundBracket}
name="withUndergroundBracket"
id="withUndergroundBracket"
/>
<FormMessage type="info">
Optional bracket for teams who lose in the first two rounds of
losers bracket.
</FormMessage>
</div>
) : null}
{format === "RR_TO_SE" ? (
<div>
<Label htmlFor="teamsPerGroup">Teams per group</Label>
<select
value={teamsPerGroup}
onChange={(e) => setTeamsPerGroup(Number(e.target.value))}
className="w-max"
name="teamsPerGroup"
id="teamsPerGroup"
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{format === "SWISS_TO_SE" ? (
<div>
<Label htmlFor="swissGroupCount">Swiss groups count</Label>
<select
value={swissGroupCount}
onChange={(e) => setSwissGroupCount(Number(e.target.value))}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{/* Without a follow-up bracket there can only be one swiss group */}
{format === "SWISS" ? (
<input type="hidden" name="swissGroupCount" value="1" />
) : null}
{format === "SWISS" || format === "SWISS_TO_SE" ? (
<div>
<Label htmlFor="swissRoundCount">Swiss round count</Label>
<select
value={swissRoundCount}
onChange={(e) => setSwissRoundCount(Number(e.target.value))}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
{format === "SWISS" ? (
<FormMessage type="info">
In swiss using the correct round count corresponding to the player
count is recommended. Examples: at most 16 players = 4 rounds, at
most 32 players = 5 rounds, at most 64 players = 6 rounds.
</FormMessage>
) : null}
</div>
) : null}
{format === "RR_TO_SE" ||
format === "SWISS_TO_SE" ||
format === "SE" ||
(format === "DE" && withUndergroundBracket) ? (
<div>
<Label htmlFor="thirdPlaceMatch">Third place match</Label>
<Toggle
checked={thirdPlaceMatch}
setChecked={setThirdPlaceMatch}
name="thirdPlaceMatch"
id="thirdPlaceMatch"
tiny
/>
</div>
) : null}
{format === "RR_TO_SE" || format === "SWISS_TO_SE" ? (
<FollowUpBrackets teamsPerGroup={teamsPerGroup} format={format} />
) : null}
</div>
);
}
function MemberCountSelect() {
const baseEvent = useBaseEvent();
const id = React.useId();
@ -1346,281 +1220,3 @@ function MemberCountSelect() {
</div>
);
}
function FollowUpBrackets({
teamsPerGroup,
format,
}: {
teamsPerGroup: number;
format: TournamentFormatShort;
}) {
const baseEvent = useBaseEvent();
const [autoCheckInAll, setAutoCheckInAll] = React.useState(
baseEvent?.tournament?.ctx.settings.autoCheckInAll ?? false,
);
const [_brackets, setBrackets] = React.useState<Array<FollowUpBracket>>(
() => {
if (
baseEvent?.tournament &&
["round_robin", "swiss"].includes(
baseEvent.tournament.ctx.settings.bracketProgression[0].type,
)
) {
return baseEvent.tournament.ctx.settings.bracketProgression
.slice(1)
.map((b) => ({
name: b.name,
placements: b.sources?.flatMap((s) => s.placements) ?? [],
}));
}
return [{ name: "Top cut", placements: [1, 2] }];
},
);
const brackets = _brackets.map((b) => ({
...b,
// handle teams per group changing after group placements have been set
placements:
format === "RR_TO_SE"
? b.placements.filter((p) => p <= teamsPerGroup)
: b.placements,
}));
const validationErrorMsg = validateFollowUpBrackets(
brackets,
format,
teamsPerGroup,
);
return (
<>
{brackets.length > 1 ? (
<div>
<Label htmlFor="autoCheckInAll">
Auto check-in to follow-up brackets
</Label>
<Toggle
checked={autoCheckInAll}
setChecked={setAutoCheckInAll}
name="autoCheckInAll"
id="autoCheckInAll"
tiny
/>
<FormMessage type="info">
If disabled, the only follow-up bracket with automatic check-in is
the top cut
</FormMessage>
</div>
) : null}
<div>
<RequiredHiddenInput
isValid={!validationErrorMsg}
name="followUpBrackets"
value={JSON.stringify(brackets)}
/>
<Label>Follow-up brackets</Label>
<div className="stack lg">
{brackets.map((b, i) => (
<FollowUpBracketInputs
key={i}
teamsPerGroup={teamsPerGroup}
onChange={(newBracket) => {
setBrackets(
brackets.map((oldBracket, j) =>
j === i ? newBracket : oldBracket,
),
);
}}
bracket={b}
nth={i + 1}
format={format}
/>
))}
<div className="stack sm horizontal">
<Button
size="tiny"
onClick={() => {
const currentMaxPlacement = Math.max(
...brackets.flatMap((b) => b.placements),
);
const placements =
format === "RR_TO_SE"
? []
: [currentMaxPlacement + 1, currentMaxPlacement + 2];
setBrackets([...brackets, { name: "", placements }]);
}}
data-testid="add-bracket"
>
Add bracket
</Button>
<Button
size="tiny"
variant="destructive"
onClick={() => {
setBrackets(brackets.slice(0, -1));
}}
disabled={brackets.length === 1}
testId="remove-bracket"
>
Remove bracket
</Button>
</div>
{validationErrorMsg ? (
<FormMessage type="error">{validationErrorMsg}</FormMessage>
) : null}
</div>
</div>
</>
);
}
function FollowUpBracketInputs({
teamsPerGroup,
bracket,
onChange,
nth,
format,
}: {
teamsPerGroup: number;
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
nth: number;
format: TournamentFormatShort;
}) {
const id = React.useId();
return (
<div className="stack sm">
<div className="stack items-center horizontal sm">
<Label spaced={false} htmlFor={id}>
{nth}. Name
</Label>
<Input
value={bracket.name}
onChange={(e) => onChange({ ...bracket, name: e.target.value })}
id={id}
/>
</div>
{format === "RR_TO_SE" ? (
<FollowUpBracketGroupPlacementCheckboxes
teamsPerGroup={teamsPerGroup}
bracket={bracket}
onChange={onChange}
nth={nth}
/>
) : (
<FollowUpBracketRangeInputs bracket={bracket} onChange={onChange} />
)}
</div>
);
}
function FollowUpBracketGroupPlacementCheckboxes({
teamsPerGroup,
bracket,
onChange,
nth,
}: {
teamsPerGroup: number;
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
nth: number;
}) {
const id = React.useId();
return (
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements</Label>
{nullFilledArray(teamsPerGroup).map((_, i) => {
const placement = i + 1;
return (
<div key={placement} className="stack horizontal items-center xs">
<Label spaced={false} htmlFor={id}>
<Placement placement={placement} />
</Label>
<input
id={id}
data-testid={`placement-${nth}-${placement}`}
type="checkbox"
checked={bracket.placements.includes(placement)}
onChange={(e) => {
const newPlacements = e.target.checked
? [...bracket.placements, placement]
: bracket.placements.filter((p) => p !== placement);
onChange({ ...bracket, placements: newPlacements });
}}
/>
</div>
);
})}
</div>
);
}
const rangeToPlacements = ([start, end]: [number, number]) => {
if (start > end) {
return [];
}
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
const placementsToRange = (placements: number[]): [number, number] => {
if (placements.length === 0) {
return [1, 2];
}
return [placements[0], placements[placements.length - 1]];
};
function FollowUpBracketRangeInputs({
bracket,
onChange,
}: {
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
}) {
const [range, setRange] = React.useState<[number, number]>(
placementsToRange(bracket.placements),
);
const handleRangeChange = (newRange: [number, number]) => {
setRange(newRange);
onChange({ ...bracket, placements: rangeToPlacements(newRange) });
};
return (
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements (both inclusive)</Label>
<div className="stack horizontal sm items-center text-xs">
from
<Input
className="calendar-new__range-input"
type="number"
value={String(range[0])}
onChange={(e) =>
handleRangeChange([Number(e.target.value), range[1]])
}
/>
to
<Input
className="calendar-new__range-input"
type="number"
value={String(range[1])}
onChange={(e) =>
handleRangeChange([range[0], Number(e.target.value)])
}
/>
</div>
</div>
);
}

View File

@ -61,8 +61,7 @@ export function BracketMapListDialog({
PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: untrimmedPreparedMaps,
teamCount: bracketTeamsCount,
tournament,
type: bracket.type,
bracket,
})
: untrimmedPreparedMaps;
@ -82,7 +81,6 @@ export function BracketMapListDialog({
const bracketData = isPreparing
? teamCountAdjustedBracketData({
bracket,
tournament,
teamCount: eliminationTeamCount,
})
: bracket.data;
@ -290,7 +288,6 @@ export function BracketMapListDialog({
setCount={(newCount) => {
const newBracketData = teamCountAdjustedBracketData({
bracket,
tournament,
teamCount: newCount,
});
@ -569,30 +566,23 @@ function authorIdToUsername(tournament: Tournament, authorId: number) {
function teamCountAdjustedBracketData({
bracket,
tournament,
teamCount,
}: { bracket: Bracket; tournament: Tournament; teamCount: number }) {
}: { bracket: Bracket; teamCount: number }) {
switch (bracket.type) {
case "swiss":
// always has the same amount of rounds even if 0 participants
return bracket.data;
case "round_robin":
// ensure a full bracket (no bye round) gets generated even if registration is underway
return tournament.generateMatchesData(
return bracket.generateMatchesData(
nullFilledArray(TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP).map(
(_, i) => i + 1,
),
bracket.type,
);
case "single_elimination":
return tournament.generateMatchesData(
nullFilledArray(teamCount).map((_, i) => i + 1),
"single_elimination",
);
case "double_elimination":
return tournament.generateMatchesData(
return bracket.generateMatchesData(
nullFilledArray(teamCount).map((_, i) => i + 1),
"double_elimination",
);
}
}

View File

@ -191,11 +191,7 @@ function _TeamRoster({
team={team}
tournamentId={tournament.ctx.id}
/>
<div
className={clsx("stack horizontal md justify-center", {
"mt-1": hasPoints && !presentational,
})}
>
<div className="stack horizontal md justify-center mt-1">
{showWinnerRadio ? (
<WinnerRadio
presentational={presentational || Boolean(revising)}

View File

@ -1,5 +1,6 @@
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import { sub } from "date-fns";
import * as React from "react";
import { LinkButton } from "~/components/Button";
import { Popover } from "~/components/Popover";
@ -37,9 +38,9 @@ export function TournamentTeamActions() {
);
}
if (status.type === "CHECKIN") {
const bracketName = tournament.brackets[status.bracketIdx ?? -1]?.name;
const bracket = tournament.brackets[status.bracketIdx ?? -1];
if (!bracketName) {
if (!bracket) {
return (
<Container spaced="very">
Your team needs to check-in
@ -75,19 +76,37 @@ export function TournamentTeamActions() {
return (
<Container spaced="very">
{bracketName} up next
<fetcher.Form method="post">
<input type="hidden" name="bracketIdx" value={status.bracketIdx} />
<SubmitButton
size="tiny"
variant="minimal"
_action="BRACKET_CHECK_IN"
state={fetcher.state}
testId="check-in-bracket-button"
>
Check-in
</SubmitButton>
</fetcher.Form>
{bracket.name} check-in
{bracket.canCheckIn(user) ? (
<fetcher.Form method="post">
<input type="hidden" name="bracketIdx" value={status.bracketIdx} />
<SubmitButton
size="tiny"
variant="minimal"
_action="BRACKET_CHECK_IN"
state={fetcher.state}
testId="check-in-bracket-button"
>
Check-in
</SubmitButton>
</fetcher.Form>
) : bracket.startTime && bracket.startTime > new Date() ? (
<span className="text-lighter text-xxs" suppressHydrationWarning>
open{" "}
{sub(bracket.startTime, { hours: 1 }).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
weekday: "short",
})}{" "}
-{" "}
{bracket.startTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</span>
) : bracket.startTime && bracket.startTime < new Date() ? (
<span className="text-warning">over</span>
) : null}
</Container>
);
}

View File

@ -1,4 +1,5 @@
import type { Tables, TournamentBracketProgression } from "~/db/tables";
import { sub } from "date-fns";
import type { Tables, TournamentStageSettings } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Round } from "~/modules/brackets-model";
@ -6,6 +7,8 @@ import { removeDuplicates } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
import * as Progression from "./Progression";
import type { OptionalIdObject, Tournament } from "./Tournament";
import type { TournamentDataTeam } from "./Tournament.server";
import { getTournamentManager } from "./brackets-manager";
@ -13,8 +16,10 @@ import type { BracketMapCounts } from "./toMapList";
interface CreateBracketArgs {
id: number;
/** Index of the bracket in the bracket progression */
idx: number;
preview: boolean;
data: TournamentManagerDataSet;
data?: TournamentManagerDataSet;
type: Tables["TournamentStage"]["type"];
canBeStarted?: boolean;
name: string;
@ -26,6 +31,9 @@ interface CreateBracketArgs {
placements: number[];
}[];
seeding?: number[];
settings: TournamentStageSettings | null;
checkInRequired: boolean;
startTime: Date | null;
}
export interface Standing {
@ -46,6 +54,7 @@ export interface Standing {
export abstract class Bracket {
id;
idx;
preview;
data;
simulatedData: TournamentManagerDataSet | undefined;
@ -56,9 +65,13 @@ export abstract class Bracket {
sources;
createdAt;
seeding;
settings;
checkInRequired;
startTime;
constructor({
id,
idx,
preview,
data,
canBeStarted,
@ -68,19 +81,32 @@ export abstract class Bracket {
sources,
createdAt,
seeding,
settings,
checkInRequired,
startTime,
}: Omit<CreateBracketArgs, "format">) {
if (!data && !seeding) {
throw new Error("Bracket: seeding or data required");
}
this.id = id;
this.idx = idx;
this.preview = preview;
this.data = data;
this.seeding = seeding;
this.tournament = tournament;
this.data = data ?? this.generateMatchesData(this.seeding!);
this.canBeStarted = canBeStarted;
this.name = name;
this.teamsPendingCheckIn = teamsPendingCheckIn;
this.tournament = tournament;
this.sources = sources;
this.createdAt = createdAt;
this.seeding = seeding;
this.settings = settings;
this.checkInRequired = checkInRequired;
this.startTime = startTime;
this.createdSimulation();
if (this.tournament.simulateBrackets) {
this.createdSimulation();
}
}
private createdSimulation() {
@ -215,7 +241,7 @@ export abstract class Bracket {
return false;
}
get type(): TournamentBracketProgression[number]["type"] {
get type(): Tables["TournamentStage"]["type"] {
throw new Error("not implemented");
}
@ -253,14 +279,44 @@ export abstract class Bracket {
});
}
generateMatchesData(teams: number[]) {
const manager = getTournamentManager();
// we need some number but does not matter what it is as the manager only contains one tournament
const virtualTournamentId = 1;
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
manager.create({
tournamentId: virtualTournamentId,
name: "Virtual",
type: this.type,
seeding:
this.type === "round_robin"
? teams
: fillWithNullTillPowerOfTwo(teams),
settings: this.tournament.bracketSettings(
this.settings,
this.type,
teams.length,
),
});
}
return manager.get.tournamentData(virtualTournamentId);
}
get isUnderground() {
return Boolean(
this.sources?.flatMap((s) => s.placements).every((p) => p !== 1),
return Progression.isUnderground(
this.idx,
this.tournament.ctx.settings.bracketProgression,
);
}
get isFinals() {
return Boolean(this.sources?.some((s) => s.placements.includes(1)));
return Progression.isFinals(
this.idx,
this.tournament.ctx.settings.bracketProgression,
);
}
get everyMatchOver() {
@ -293,6 +349,14 @@ export abstract class Bracket {
// using regular check-in
if (!this.teamsPendingCheckIn) return false;
if (this.startTime) {
const checkInOpen =
sub(this.startTime.getTime(), { hours: 1 }).getTime() < Date.now() &&
this.startTime.getTime() > Date.now();
if (!checkInOpen) return false;
}
const team = this.tournament.ownedTeamByUser(user);
if (!team) return false;
@ -348,7 +412,7 @@ export abstract class Bracket {
}
class SingleEliminationBracket extends Bracket {
get type(): TournamentBracketProgression[number]["type"] {
get type(): Tables["TournamentStage"]["type"] {
return "single_elimination";
}
@ -502,7 +566,7 @@ class SingleEliminationBracket extends Bracket {
}
class DoubleEliminationBracket extends Bracket {
get type(): TournamentBracketProgression[number]["type"] {
get type(): Tables["TournamentStage"]["type"] {
return "double_elimination";
}
@ -1050,7 +1114,7 @@ class RoundRobinBracket extends Bracket {
);
}
get type(): TournamentBracketProgression[number]["type"] {
get type(): Tables["TournamentStage"]["type"] {
return "round_robin";
}
@ -1423,7 +1487,7 @@ class SwissBracket extends Bracket {
);
}
get type(): TournamentBracketProgression[number]["type"] {
get type(): Tables["TournamentStage"]["type"] {
return "swiss";
}

View File

@ -11,11 +11,15 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => {
{
type: "round_robin",
name: "Round Robin",
requiresCheckIn: false,
settings: {},
sources: [],
},
{
type: "single_elimination",
name: "Top Cut",
requiresCheckIn: false,
settings: {},
sources: [
{
bracketIdx: 0,
@ -26,6 +30,8 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => {
{
type: "single_elimination",
name: "Underground Bracket",
requiresCheckIn: false,
settings: {},
sources: [
{
bracketIdx: 0,
@ -120,8 +126,14 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const tournament = testTournament({
ctx: {
settings: {
thirdPlaceMatch: true,
bracketProgression: [],
bracketProgression: [
{
type: "single_elimination",
settings: { thirdPlaceMatch: true },
name: "X",
requiresCheckIn: false,
},
],
},
},
});
@ -130,8 +142,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: null,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed).toBeNull();
@ -141,8 +152,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: FOUR_TEAM_SE_PREPARED,
teamCount: 8,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed).toBeNull();
@ -156,8 +166,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: copy,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed).toBeNull();
@ -167,8 +176,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: FOUR_TEAM_SE_PREPARED,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed).toBe(FOUR_TEAM_SE_PREPARED);
@ -178,8 +186,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_SE_PREPARED,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 1);
@ -189,8 +196,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_SE_PREPARED,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed?.maps[0].list?.[0].stageId).toBe(
@ -202,14 +208,12 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_SE_PREPARED,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
const actualBracket = tournament.generateMatchesData(
[1, 2, 3, 4],
"single_elimination",
);
const actualBracket = tournament
.bracketByIdx(0)!
.generateMatchesData([1, 2, 3, 4]);
for (const round of actualBracket.round) {
expect(
@ -223,8 +227,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_SE_PREPARED,
teamCount: 4,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed?.maps[0].roundId).toBe(0);
@ -234,8 +237,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_SE_PREPARED,
teamCount: 3,
tournament,
type: "single_elimination",
bracket: tournament.bracketByIdx(0)!,
});
expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 2);
@ -245,12 +247,26 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
expect(uniqueGroupIds.size).toBe(1);
});
const doubleEliminationTournament = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "double_elimination",
settings: { thirdPlaceMatch: true },
name: "X",
requiresCheckIn: false,
},
],
},
},
});
test("trims the maps (DE - both winners and losers)", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_DE_PREPARED,
teamCount: 4,
tournament,
type: "double_elimination",
bracket: doubleEliminationTournament.bracketByIdx(0)!,
});
const expectedWinnersCount = 2;
@ -275,14 +291,12 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => {
const trimmed = PreparedMaps.trimPreparedEliminationMaps({
preparedMaps: EIGHT_TEAM_DE_PREPARED,
teamCount: 4,
tournament,
type: "double_elimination",
bracket: doubleEliminationTournament.bracketByIdx(0)!,
});
const actualBracket = tournament.generateMatchesData(
[1, 2, 3, 4],
"double_elimination",
);
const actualBracket = doubleEliminationTournament
.bracketByIdx(0)!
.generateMatchesData([1, 2, 3, 4]);
for (const round of actualBracket.round) {
expect(

View File

@ -2,6 +2,7 @@ import compare from "just-compare";
import type { PreparedMaps } from "~/db/tables";
import { nullFilledArray, removeDuplicates } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import type { Bracket } from "./Bracket";
import type { Tournament } from "./Tournament";
/** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same sources */
@ -71,8 +72,7 @@ export function isValidMaxEliminationTeamCount(count: number) {
interface TrimPreparedEliminationMapsAgs {
preparedMaps: PreparedMaps | null;
teamCount: number;
tournament: Tournament;
type: "double_elimination" | "single_elimination";
bracket: Bracket;
}
/** Trim prepared elimination bracket maps to match the actual number. If not prepared or prepared for too few returns null */
@ -109,12 +109,10 @@ export function trimPreparedEliminationMaps({
function trimMapsByTeamCount({
preparedMaps,
teamCount,
tournament,
type,
bracket,
}: TrimPreparedEliminationMapsAgs & { preparedMaps: PreparedMaps }) {
const actualRounds = tournament.generateMatchesData(
const actualRounds = bracket.generateMatchesData(
nullFilledArray(teamCount).map((_, i) => i + 1),
type,
).round;
const groupIds = removeDuplicates(preparedMaps.maps.map((r) => r.groupId));

View File

@ -0,0 +1,538 @@
import { describe, expect, it } from "vitest";
import * as Progression from "./Progression";
import { progressions } from "./tests/test-utils";
describe("bracketsToValidationError - valid formats", () => {
it("accepts SE", () => {
expect(
Progression.bracketsToValidationError(progressions.singleElimination),
).toBeNull();
});
it("accepts RR->SE", () => {
expect(
Progression.bracketsToValidationError(
progressions.roundRobinToSingleElimination,
),
).toBeNull();
});
it("accepts low ink", () => {
expect(
Progression.bracketsToValidationError(progressions.lowInk),
).toBeNull();
});
it("accepts many starter brackets", () => {
expect(
Progression.bracketsToValidationError(progressions.manyStartBrackets),
).toBeNull();
});
it("accepts swiss (one group)", () => {
expect(
Progression.bracketsToValidationError(progressions.swissOneGroup),
).toBeNull();
});
});
describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => {
const getValidatedBracketsFromPlacements = (placements: string) => {
return Progression.validatedBrackets([
{
id: "1",
name: "Bracket 1",
type: "round_robin",
settings: {},
requiresCheckIn: false,
},
{
id: "2",
name: "Bracket 2",
type: "single_elimination",
settings: {},
requiresCheckIn: false,
sources: [
{
bracketId: "1",
placements,
},
],
},
]);
};
it("parses placements correctly (separated by comma)", () => {
const result = getValidatedBracketsFromPlacements(
"1,2,3,4",
) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([
{ bracketIdx: 0, placements: [1, 2, 3, 4] },
]);
});
it("parses placements correctly (separated by line)", () => {
const result = getValidatedBracketsFromPlacements(
"1-4",
) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([
{ bracketIdx: 0, placements: [1, 2, 3, 4] },
]);
});
it("parses placements correctly (separated by a mix)", () => {
const result = getValidatedBracketsFromPlacements(
"1,2,3-4",
) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([
{ bracketIdx: 0, placements: [1, 2, 3, 4] },
]);
});
it("handles placement where ranges start and end is the same", () => {
const result = getValidatedBracketsFromPlacements(
"1-1",
) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([{ bracketIdx: 0, placements: [1] }]);
});
it("handles parsing with extra white space", () => {
const result = getValidatedBracketsFromPlacements(
"1, 2, 3,4 ",
) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([
{ bracketIdx: 0, placements: [1, 2, 3, 4] },
]);
});
it("handles parsing with negative placements", () => {
const result = Progression.validatedBrackets([
{
id: "1",
name: "Bracket 1",
type: "double_elimination",
settings: {},
requiresCheckIn: false,
},
{
id: "2",
name: "Bracket 2",
type: "single_elimination",
settings: {},
requiresCheckIn: false,
sources: [
{
bracketId: "1",
placements: "-1,-2",
},
],
},
]) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([
{ bracketIdx: 0, placements: [-1, -2] },
]);
});
it("parsing fails if invalid characters", () => {
const error = getValidatedBracketsFromPlacements(
"1st,2nd,3rd,4th",
) as Progression.ValidationError;
expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
});
});
const getValidatedBrackets = (
brackets: (Omit<
Progression.InputBracket,
"id" | "name" | "requiresCheckIn"
> & { name?: string })[],
) =>
Progression.validatedBrackets(
brackets.map((b, i) => ({
id: String(i),
name: b.name ?? `Bracket ${i + 1}`,
requiresCheckIn: false,
...b,
})),
);
describe("validatedSources - other rules", () => {
it("handles NOT_RESOLVING_WINNER (only round robin)", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
]) as Progression.ValidationError;
expect(error.type).toBe("NOT_RESOLVING_WINNER");
});
it("handles NOT_RESOLVING_WINNER (ends in round robin)", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "single_elimination",
},
{
settings: {},
type: "round_robin",
sources: [
{
bracketId: "0",
placements: "1,2",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("NOT_RESOLVING_WINNER");
});
it("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => {
const error = getValidatedBrackets([
{
settings: {
groupCount: 2,
},
type: "swiss",
},
]) as Progression.ValidationError;
expect(error.type).toBe("NOT_RESOLVING_WINNER");
});
it("handles SAME_PLACEMENT_TO_MULTIPLE_BRACKETS", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1-2",
},
],
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "2-3",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
expect((error as any).bracketIdxs).toEqual([1, 2]);
});
it("handles GAP_IN_PLACEMENTS", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1",
},
],
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "3",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("GAP_IN_PLACEMENTS");
expect((error as any).bracketIdxs).toEqual([1, 2]);
});
it("handles TOO_MANY_PLACEMENTS", () => {
const error = getValidatedBrackets([
{
settings: {
teamsPerGroup: 4,
},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1,2,3,4,5",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("TOO_MANY_PLACEMENTS");
expect((error as any).bracketIdx).toEqual(1);
});
it("handles DUPLICATE_BRACKET_NAME", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
name: "Bracket 1",
},
{
settings: {},
type: "single_elimination",
name: "Bracket 1",
sources: [
{
bracketId: "0",
placements: "1-2",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("DUPLICATE_BRACKET_NAME");
expect((error as any).bracketIdxs).toEqual([0, 1]);
});
it("handles NAME_MISSING", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
name: "",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1-2",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("NAME_MISSING");
expect((error as any).bracketIdx).toEqual(0);
});
it("handles NEGATIVE_PROGRESSION", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "-1,-2",
},
],
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("NEGATIVE_PROGRESSION");
expect((error as any).bracketIdx).toEqual(1);
});
it("handles NO_SE_SOURCE", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "single_elimination",
},
{
settings: {},
type: "double_elimination",
sources: [
{
bracketId: "0",
placements: "1-2",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("NO_SE_SOURCE");
expect((error as any).bracketIdx).toEqual(1);
});
it("handles NO_DE_POSITIVE", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "double_elimination",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1-2",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("NO_DE_POSITIVE");
expect((error as any).bracketIdx).toEqual(1);
});
it("throws an error if many missing sources", () => {
expect(() =>
getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
},
]),
).toThrow();
});
});
describe("isFinals", () => {
it("handles SE", () => {
expect(Progression.isFinals(0, progressions.singleElimination)).toBe(true);
});
it("handles RR->SE", () => {
expect(
Progression.isFinals(0, progressions.roundRobinToSingleElimination),
).toBe(false);
expect(
Progression.isFinals(1, progressions.roundRobinToSingleElimination),
).toBe(true);
});
it("handles low ink", () => {
expect(Progression.isFinals(0, progressions.lowInk)).toBe(false);
expect(Progression.isFinals(1, progressions.lowInk)).toBe(false);
expect(Progression.isFinals(2, progressions.lowInk)).toBe(false);
expect(Progression.isFinals(3, progressions.lowInk)).toBe(true);
});
it("many starter brackets", () => {
expect(Progression.isFinals(0, progressions.manyStartBrackets)).toBe(false);
expect(Progression.isFinals(1, progressions.manyStartBrackets)).toBe(false);
expect(Progression.isFinals(2, progressions.manyStartBrackets)).toBe(true);
expect(Progression.isFinals(3, progressions.manyStartBrackets)).toBe(false);
});
it("throws if given idx is out of bounds", () => {
expect(() =>
Progression.isFinals(1, progressions.singleElimination),
).toThrow();
});
});
describe("isUnderground", () => {
it("handles SE", () => {
expect(Progression.isUnderground(0, progressions.singleElimination)).toBe(
false,
);
});
it("handles RR->SE", () => {
expect(
Progression.isUnderground(0, progressions.roundRobinToSingleElimination),
).toBe(false);
expect(
Progression.isUnderground(1, progressions.roundRobinToSingleElimination),
).toBe(false);
});
it("handles low ink", () => {
expect(Progression.isUnderground(0, progressions.lowInk)).toBe(false);
expect(Progression.isUnderground(1, progressions.lowInk)).toBe(true);
expect(Progression.isUnderground(2, progressions.lowInk)).toBe(false);
expect(Progression.isUnderground(3, progressions.lowInk)).toBe(false);
});
it("many starter brackets", () => {
expect(Progression.isUnderground(0, progressions.manyStartBrackets)).toBe(
false,
);
expect(Progression.isUnderground(1, progressions.manyStartBrackets)).toBe(
true,
);
expect(Progression.isUnderground(2, progressions.manyStartBrackets)).toBe(
false,
);
expect(Progression.isUnderground(3, progressions.manyStartBrackets)).toBe(
true,
);
});
it("throws if given idx is out of bounds", () => {
expect(() =>
Progression.isUnderground(1, progressions.singleElimination),
).toThrow();
});
});
describe("changedBracketProgression", () => {
it("reports changed bracket indexes", () => {
const withChanges = structuredClone(progressions.lowInk);
withChanges[0].name = "New name";
withChanges[1].type = "swiss";
expect(
Progression.changedBracketProgression(progressions.lowInk, withChanges),
).toEqual([0, 1]);
});
it("returns an empty array if nothing changed", () => {
expect(
Progression.changedBracketProgression(
progressions.lowInk,
progressions.lowInk,
),
).toEqual([]);
});
});

View File

@ -0,0 +1,626 @@
// todo
import compare from "just-compare";
import type { Tables, TournamentStageSettings } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "../../../utils/invariant";
export interface DBSource {
/** Index of the bracket where the teams come from */
bracketIdx: number;
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. */
placements: number[];
}
export interface EditableSource {
/** Bracket ID that exists in frontend only while editing. Once the sources are set an index is used to identifyer them instead. See DBSource.bracketIdx for more info. */
bracketId: string;
/** User editable string of placements. For example might be "1-3" or "1,2,3" which both mean same thing. See DBSource.placements for the validated and serialized version. */
placements: string;
}
interface BracketBase {
type: Tables["TournamentStage"]["type"];
settings: TournamentStageSettings;
name: string;
requiresCheckIn: boolean;
}
// Note sources is array for future proofing reasons. Currently the array is always of length 1 if it exists.
export interface InputBracket extends BracketBase {
id: string;
sources?: EditableSource[];
startTime?: Date;
/** This bracket cannot be edited (because it is already underway) */
disabled?: boolean;
}
export interface ParsedBracket extends BracketBase {
sources?: DBSource[];
startTime?: number;
}
export type ValidationError =
// user written placements can not be parsed
| {
type: "PLACEMENTS_PARSE_ERROR";
bracketIdx: number;
}
// tournament is ending with a format that does not resolve a winner such as round robin or grouped swiss
| {
type: "NOT_RESOLVING_WINNER";
}
// from each bracket one placement can lead to only one bracket
| {
type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS";
bracketIdxs: number[];
}
// from one bracket e.g. if 1st goes somewhere and 3rd goes somewhere then 2nd must also go somewhere
| {
type: "GAP_IN_PLACEMENTS";
bracketIdxs: number[];
}
// if round robin groups size is 4 then it doesn't make sense to have destination for 5
| {
type: "TOO_MANY_PLACEMENTS";
bracketIdx: number;
}
// two brackets can not have the same name
| {
type: "DUPLICATE_BRACKET_NAME";
bracketIdxs: number[];
}
// all brackets must have a name that is not an empty string
| {
type: "NAME_MISSING";
bracketIdx: number;
}
// negative progression (e.g. losers of first round go somewhere) is only for elimination bracket
| {
type: "NEGATIVE_PROGRESSION";
bracketIdx: number;
}
// single elimination is not a valid source bracket (might change in the future)
| {
type: "NO_SE_SOURCE";
bracketIdx: number;
}
// no DE positive placements (might change in the future)
| {
type: "NO_DE_POSITIVE";
bracketIdx: number;
};
/** Takes validated brackets and returns them in the format that is ready for user input. */
export function validatedBracketsToInputFormat(
brackets: ParsedBracket[],
): InputBracket[] {
return brackets.map((bracket, bracketIdx) => {
return {
id: String(bracketIdx),
name: bracket.name,
settings: bracket.settings ?? {},
type: bracket.type,
requiresCheckIn: bracket.requiresCheckIn ?? false,
startTime: bracket.startTime
? databaseTimestampToDate(bracket.startTime)
: undefined,
sources: bracket.sources?.map((source) => ({
bracketId: String(source.bracketIdx),
placements: placementsToString(source.placements),
})),
};
});
}
function placementsToString(placements: number[]): string {
if (placements.length === 0) return "";
placements.sort((a, b) => a - b);
if (placements.some((p) => p < 0)) {
placements.sort((a, b) => b - a);
return placements.join(",");
}
const ranges: string[] = [];
let start = placements[0];
let end = placements[0];
for (let i = 1; i < placements.length; i++) {
if (placements[i] === end + 1) {
end = placements[i];
} else {
if (start === end) {
ranges.push(`${start}`);
} else {
ranges.push(`${start}-${end}`);
}
start = placements[i];
end = placements[i];
}
}
if (start === end) {
ranges.push(String(start));
} else {
ranges.push(`${start}-${end}`);
}
return ranges.join(",");
}
/** Takes bracket progression as entered by user as input and returns the validated brackets ready for input to the database or errors if any. */
export function validatedBrackets(
brackets: InputBracket[],
): ParsedBracket[] | ValidationError {
let parsed: ParsedBracket[];
try {
parsed = toOutputBracketFormat(brackets);
} catch (e) {
if ((e as { badBracketIdx: number }).badBracketIdx) {
return {
type: "PLACEMENTS_PARSE_ERROR",
bracketIdx: (e as { badBracketIdx: number }).badBracketIdx,
};
}
throw e;
}
validateOnlyOneEntry(parsed);
const validationError = bracketsToValidationError(parsed);
if (validationError) {
return validationError;
}
return parsed;
}
function validateOnlyOneEntry(brackets: ParsedBracket[]) {
const entryBracketCount = brackets.filter(
(bracket) => !bracket.sources,
).length;
if (entryBracketCount !== 1) {
throw new Error("Only one bracket can have no sources");
}
}
/** Checks parsed brackets for any errors related to how the progression is laid out */
export function bracketsToValidationError(
brackets: ParsedBracket[],
): ValidationError | null {
if (!resolvesWinner(brackets)) {
return {
type: "NOT_RESOLVING_WINNER",
};
}
let faultyBracketIdxs: number[] | null = null;
faultyBracketIdxs = samePlacementToMultipleBrackets(brackets);
if (faultyBracketIdxs) {
return {
type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS",
bracketIdxs: faultyBracketIdxs,
};
}
faultyBracketIdxs = duplicateNames(brackets);
if (faultyBracketIdxs) {
return {
type: "DUPLICATE_BRACKET_NAME",
bracketIdxs: faultyBracketIdxs,
};
}
faultyBracketIdxs = gapInPlacements(brackets);
if (faultyBracketIdxs) {
return {
type: "GAP_IN_PLACEMENTS",
bracketIdxs: faultyBracketIdxs,
};
}
let faultyBracketIdx: number | null = null;
faultyBracketIdx = tooManyPlacements(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "TOO_MANY_PLACEMENTS",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = nameMissing(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NAME_MISSING",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = negativeProgression(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NEGATIVE_PROGRESSION",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = noSingleEliminationAsSource(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NO_SE_SOURCE",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = noDoubleEliminationPositive(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NO_DE_POSITIVE",
bracketIdx: faultyBracketIdx,
};
}
return null;
}
function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
const result = brackets.map((bracket, bracketIdx) => {
return {
type: bracket.type,
settings: bracket.settings,
name: bracket.name,
requiresCheckIn: bracket.requiresCheckIn,
startTime: bracket.startTime
? dateToDatabaseTimestamp(bracket.startTime)
: undefined,
sources: bracket.sources?.map((source) => {
const placements = parsePlacements(source.placements);
if (!placements) {
throw { badBracketIdx: bracketIdx };
}
return {
bracketIdx: brackets.findIndex((b) => b.id === source.bracketId),
placements,
};
}),
};
});
invariant(
result.every(
(bracket) =>
!bracket.sources ||
bracket.sources.every((source) => source.bracketIdx >= 0),
"Bracket source not found",
),
);
return result;
}
function parsePlacements(placements: string) {
const parts = placements.split(",");
const result: number[] = [];
for (let part of parts) {
part = part.trim();
const isNegative = part.match(/^-\d+$/);
if (isNegative) {
result.push(Number(part));
continue;
}
const isValid = part.match(/^\d+(-\d+)?$/);
if (!isValid) return null;
if (part.includes("-")) {
const [start, end] = part.split("-").map(Number);
for (let i = start; i <= end; i++) {
result.push(i);
}
} else {
result.push(Number(part));
}
}
return result;
}
function resolvesWinner(brackets: ParsedBracket[]) {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
if (finals?.type === "round_robin") return false;
if (
finals.type === "swiss" &&
(finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1
) {
return false;
}
return true;
}
function samePlacementToMultipleBrackets(brackets: ParsedBracket[]) {
const map = new Map<string, number[]>();
for (const [bracketIdx, bracket] of brackets.entries()) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
for (const placement of source.placements) {
const id = `${source.bracketIdx}-${placement}`;
if (!map.has(id)) {
map.set(id, []);
}
map.get(id)!.push(bracketIdx);
}
}
}
const result: number[] = [];
for (const [_, bracketIdxs] of map) {
if (bracketIdxs.length > 1) {
result.push(...bracketIdxs);
}
}
return result.length ? result : null;
}
function duplicateNames(brackets: ParsedBracket[]) {
const names = new Set<string>();
for (const [bracketIdx, bracket] of brackets.entries()) {
if (names.has(bracket.name)) {
return [brackets.findIndex((b) => b.name === bracket.name), bracketIdx];
}
names.add(bracket.name);
}
return null;
}
function gapInPlacements(brackets: ParsedBracket[]) {
const placementsMap = new Map<number, number[]>();
for (const bracket of brackets) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
if (!placementsMap.has(source.bracketIdx)) {
placementsMap.set(source.bracketIdx, []);
}
placementsMap.get(source.bracketIdx)!.push(...source.placements);
}
}
let problematicBracketIdx: number | null = null;
for (const [sourceBracketIdx, placements] of placementsMap.entries()) {
if (problematicBracketIdx !== null) break;
const placementsToConsider = placements
.filter((placement) => placement > 0)
.sort((a, b) => a - b);
for (let i = 0; i < placementsToConsider.length - 1; i++) {
if (placementsToConsider[i] + 1 !== placementsToConsider[i + 1]) {
problematicBracketIdx = sourceBracketIdx;
break;
}
}
}
if (problematicBracketIdx === null) return null;
return brackets.flatMap((bracket, bracketIdx) => {
if (!bracket.sources) return [];
return bracket.sources.flatMap(
(source) => source.bracketIdx === problematicBracketIdx,
)
? [bracketIdx]
: [];
});
}
function tooManyPlacements(brackets: ParsedBracket[]) {
const roundRobins = brackets.flatMap((bracket, bracketIdx) =>
bracket.type === "round_robin" ? [bracketIdx] : [],
);
// technically not correct but i guess not too common to have different round robins in the same bracket
const size = Math.min(
...roundRobins.map(
(bracketIdx) =>
brackets[bracketIdx].settings.teamsPerGroup ?? Number.POSITIVE_INFINITY,
),
);
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
if (
roundRobins.includes(source.bracketIdx) &&
source.placements.some((placement) => placement > size)
) {
return bracketIdx;
}
}
}
return null;
}
function nameMissing(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
if (!bracket.name) {
return bracketIdx;
}
}
return null;
}
function negativeProgression(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (
sourceBracket.type === "double_elimination" ||
sourceBracket.type === "single_elimination"
) {
continue;
}
if (source.placements.some((placement) => placement < 0)) {
return bracketIdx;
}
}
}
return null;
}
function noSingleEliminationAsSource(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (sourceBracket.type === "single_elimination") {
return bracketIdx;
}
}
}
return null;
}
function noDoubleEliminationPositive(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (
sourceBracket.type === "double_elimination" &&
source.placements.some((placement) => placement > 0)
) {
return bracketIdx;
}
}
}
return null;
}
/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */
export function isBrackets(
input: ParsedBracket[] | ValidationError,
): input is ParsedBracket[] {
return Array.isArray(input);
}
/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a unsuccessful validation */
export function isError(
input: ParsedBracket[] | ValidationError,
): input is ValidationError {
return !Array.isArray(input);
}
/** Given bracketIdx and bracketProgression will resolve if this the "final stage" of the tournament that decides the final standings */
export function isFinals(idx: number, brackets: ParsedBracket[]) {
invariant(idx < brackets.length, "Bracket index out of bounds");
return resolveMainBracketProgression(brackets).at(-1) === idx;
}
/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket".
* Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers
*/
export function isUnderground(idx: number, brackets: ParsedBracket[]) {
invariant(idx < brackets.length, "Bracket index out of bounds");
return !resolveMainBracketProgression(brackets).includes(idx);
}
function resolveMainBracketProgression(brackets: ParsedBracket[]) {
if (brackets.length === 1) return [0];
let bracketIdxToFind = 0;
const result = [0];
while (true) {
const bracket = brackets.findIndex((bracket) =>
bracket.sources?.some(
(source) =>
source.placements.includes(1) &&
source.bracketIdx === bracketIdxToFind,
),
);
if (bracket === -1) break;
bracketIdxToFind = bracket;
result.push(bracketIdxToFind);
}
return result;
}
/** Considering all fields. Returns array of bracket indexes that were changed */
export function changedBracketProgression(
oldProgression: ParsedBracket[],
newProgression: ParsedBracket[],
) {
const changed: number[] = [];
for (let i = 0; i < oldProgression.length; i++) {
const oldBracket = oldProgression[i];
const newBracket = newProgression.at(i);
if (!newBracket || !compare(oldBracket, newBracket)) {
changed.push(i);
}
}
return changed;
}
/** Considering only fields that affect the format. Returns true if the tournament bracket format was changed and false otherwise */
export function changedBracketProgressionFormat(
oldProgression: ParsedBracket[],
newProgression: ParsedBracket[],
): boolean {
for (let i = 0; i < oldProgression.length; i++) {
const oldBracket = oldProgression[i];
const newBracket = newProgression.at(i);
// sources, startTime or requiresCheckIn are not considered
if (
!newBracket ||
newBracket.name !== oldBracket.name ||
newBracket.type !== oldBracket.type ||
!compare(newBracket.settings, oldBracket.settings)
) {
return true;
}
}
return false;
}

View File

@ -1,6 +1,7 @@
// separate from brackets-manager as this wasn't part of the original brackets-manager library
import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { InputStage, Match } from "~/modules/brackets-model";
import { nullFilledArray } from "~/utils/arrays";
@ -12,8 +13,10 @@ export function create(
): TournamentManagerDataSet {
const swissSettings = args.settings?.swiss;
const groupCount = swissSettings?.groupCount ?? 1;
const roundCount = swissSettings?.roundCount ?? 5;
const groupCount =
swissSettings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT;
const roundCount =
swissSettings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT;
const group = nullFilledArray(groupCount).map((_, i) => ({
id: i,

View File

@ -12,12 +12,17 @@ const manager = getServerTournamentManager();
export const tournamentManagerData = (tournamentId: number) =>
manager.get.tournamentData(tournamentId);
const combinedTournamentData = async (tournamentId: number) => ({
data: tournamentManagerData(tournamentId),
ctx: notFoundIfFalsy(await TournamentRepository.findById(tournamentId)),
});
const combinedTournamentData = async (tournamentId: number) => {
const ctx = await TournamentRepository.findById(tournamentId);
if (!ctx) return null;
export type TournamentData = Unwrapped<typeof tournamentData>;
return {
data: tournamentManagerData(tournamentId),
ctx,
};
};
export type TournamentData = NonNullable<Unwrapped<typeof tournamentData>>;
export type TournamentDataTeam = TournamentData["ctx"]["teams"][number];
export async function tournamentData({
user,
@ -26,9 +31,10 @@ export async function tournamentData({
user?: { id: number };
tournamentId: number;
}) {
const { data, ctx } = await combinedTournamentData(tournamentId);
const data = await combinedTournamentData(tournamentId);
if (!data) return null;
return dataMapped({ data, ctx, user });
return dataMapped({ user, ...data });
}
function dataMapped({
@ -81,7 +87,9 @@ export async function tournamentFromDB(args: {
user: { id: number } | undefined;
tournamentId: number;
}) {
return new Tournament(await tournamentData(args));
const data = notFoundIfFalsy(await tournamentData(args));
return new Tournament({ ...data, simulateBrackets: false });
}
// caching promise ensures that if many requests are made for the same tournament
@ -101,9 +109,9 @@ export async function tournamentDataCached({
tournamentDataCache.set(tournamentId, combinedTournamentData(tournamentId));
}
const { data, ctx } = await tournamentDataCache.get(tournamentId)!;
const data = notFoundIfFalsy(await tournamentDataCache.get(tournamentId));
return dataMapped({ data, ctx, user });
return dataMapped({ user, ...data });
}
export function clearTournamentDataCache(tournamentId: number) {

View File

@ -11,7 +11,16 @@ import {
describe("Follow-up bracket progression", () => {
const tournamentPP257 = new Tournament(PADDLING_POOL_257());
const tournamentPP255 = new Tournament(PADDLING_POOL_255());
const tournamentITZ32 = new Tournament(IN_THE_ZONE_32());
const tournamentITZ32 = new Tournament(IN_THE_ZONE_32({}));
const tournamentITZ32UndergroundWithoutCheckIn = new Tournament(
IN_THE_ZONE_32({ undergroundRequiresCheckIn: false }),
);
const tournamentITZ32UndergroundWithoutCheckInWithCheckedOut = new Tournament(
IN_THE_ZONE_32({
undergroundRequiresCheckIn: false,
hasCheckedOutTeam: true,
}),
);
test("correct amount of teams in the top cut", () => {
expect(tournamentPP257.brackets[1].seeding?.length).toBe(18);
@ -43,6 +52,19 @@ describe("Follow-up bracket progression", () => {
expect(tournamentITZ32.brackets[1].seeding?.length).toBe(4);
});
test("underground bracket includes all teams if does not require check in (DE->SE)", () => {
expect(
tournamentITZ32UndergroundWithoutCheckIn.brackets[1].seeding?.length,
).toBe(16);
});
test("underground bracket excludes checked out teams", () => {
expect(
tournamentITZ32UndergroundWithoutCheckInWithCheckedOut.brackets[1].seeding
?.length,
).toBe(15);
});
const AMOUNT_OF_WORSE_VS_BEST = 5;
const AMOUNT_OF_BEST_VS_BEST = 1;
const AMOUNT_OF_WORSE_VS_WORSE = 2;

View File

@ -1,9 +1,10 @@
import type {
TournamentBracketProgression,
Tables,
TournamentStage,
TournamentStageSettings,
} from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Match, Stage } from "~/modules/brackets-model";
@ -35,8 +36,17 @@ export type OptionalIdObject = { id: number } | undefined;
export class Tournament {
brackets: Bracket[] = [];
ctx;
simulateBrackets;
constructor({ data, ctx }: TournamentData) {
constructor({
data,
ctx,
simulateBrackets = true,
}: {
data: TournamentData["data"];
ctx: TournamentData["ctx"];
simulateBrackets?: boolean;
}) {
const hasStarted = data.stage.length > 0;
const teamsInSeedOrder = ctx.teams.sort((a, b) => {
@ -54,6 +64,7 @@ export class Tournament {
return this.compareUnseededTeams(a, b);
});
this.simulateBrackets = simulateBrackets;
this.ctx = {
...ctx,
teams: hasStarted
@ -94,7 +105,14 @@ export class Tournament {
private initBrackets(data: TournamentManagerDataSet) {
for (const [
bracketIdx,
{ type, name, sources },
{
type,
name,
sources,
requiresCheckIn = false,
startTime = null,
settings,
},
] of this.ctx.settings.bracketProgression.entries()) {
const inProgressStage = data.stage.find((stage) => stage.name === name);
@ -106,11 +124,15 @@ export class Tournament {
this.brackets.push(
Bracket.create({
id: inProgressStage.id,
idx: bracketIdx,
tournament: this,
preview: false,
name,
sources,
createdAt: inProgressStage.createdAt,
checkInRequired: requiresCheckIn ?? false,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
data: {
...data,
group: data.group.filter(
@ -139,20 +161,30 @@ export class Tournament {
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn: !sources,
requiresCheckIn,
});
this.brackets.push(
Bracket.create({
id: -1 * bracketIdx,
idx: bracketIdx,
tournament: this,
seeding: checkedInTeams,
preview: true,
name,
checkInRequired: requiresCheckIn ?? false,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
data: Swiss.create({
tournamentId: this.ctx.id,
name,
seeding: checkedInTeams,
settings: this.bracketSettings(type, checkedInTeams.length),
settings: this.bracketSettings(
settings,
type,
checkedInTeams.length,
),
}),
type,
sources,
@ -176,25 +208,31 @@ export class Tournament {
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn: !sources,
requiresCheckIn,
});
const checkedInTeamsWithReplaysAvoided =
this.avoidReplaysOfPreviousBracketOpponent(checkedInTeams, {
sources,
type,
});
this.avoidReplaysOfPreviousBracketOpponent(
checkedInTeams,
{
sources,
type,
},
settings,
);
this.brackets.push(
Bracket.create({
id: -1 * bracketIdx,
idx: bracketIdx,
tournament: this,
seeding: checkedInTeamsWithReplaysAvoided,
preview: true,
name,
data: this.generateMatchesData(
checkedInTeamsWithReplaysAvoided,
type,
),
checkInRequired: requiresCheckIn ?? false,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
type,
sources,
createdAt: null,
@ -210,28 +248,8 @@ export class Tournament {
}
}
generateMatchesData(teams: number[], type: TournamentStage["type"]) {
const manager = getTournamentManager();
// we need some number but does not matter what it is as the manager only contains one tournament
const virtualTournamentId = 1;
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
manager.create({
tournamentId: virtualTournamentId,
name: "Virtual",
type,
seeding:
type === "round_robin" ? teams : fillWithNullTillPowerOfTwo(teams),
settings: this.bracketSettings(type, teams.length),
});
}
return manager.get.tournamentData(virtualTournamentId);
}
private resolveTeamsFromSources(
sources: NonNullable<TournamentBracketProgression[number]["sources"]>,
sources: NonNullable<Progression.ParsedBracket["sources"]>,
) {
const teams: number[] = [];
@ -254,9 +272,10 @@ export class Tournament {
private avoidReplaysOfPreviousBracketOpponent(
teams: number[],
bracket: {
sources: TournamentBracketProgression[number]["sources"];
type: TournamentBracketProgression[number]["type"];
sources: Progression.ParsedBracket["sources"];
type: Tables["TournamentStage"]["type"];
},
settings: TournamentStageSettings,
) {
// rather arbitrary limit, but with smaller brackets avoiding replays is not possible
// and then later while loop hits iteration limit
@ -309,7 +328,11 @@ export class Tournament {
"round_robin" | "swiss"
>,
seeding: fillWithNullTillPowerOfTwo(candidateTeams),
settings: this.bracketSettings(bracket.type, candidateTeams.length),
settings: this.bracketSettings(
settings,
bracket.type,
candidateTeams.length,
),
});
const matches = manager.get.tournamentData(this.ctx.id).match;
@ -394,30 +417,47 @@ export class Tournament {
private divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn,
requiresCheckIn,
}: {
teams: number[];
bracketIdx: number;
usesRegularCheckIn: boolean;
requiresCheckIn: boolean;
}) {
return teams.reduce(
(acc, cur) => {
const team = this.teamById(cur);
invariant(team, "Team not found");
const usesRegularCheckIn = bracketIdx === 0;
if (usesRegularCheckIn) {
if (team.checkIns.length > 0 || !this.regularCheckInStartInThePast) {
acc.checkedInTeams.push(cur);
} else {
acc.notCheckedInTeams.push(cur);
}
} else {
if (
team.checkIns.some((checkIn) => checkIn.bracketIdx === bracketIdx)
) {
} else if (requiresCheckIn) {
const isCheckedIn = team.checkIns.some(
(checkIn) =>
checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
);
if (isCheckedIn) {
acc.checkedInTeams.push(cur);
} else {
acc.notCheckedInTeams.push(cur);
}
} else {
const isCheckedOut = team.checkIns.some(
(checkIn) =>
checkIn.bracketIdx === bracketIdx && checkIn.isCheckOut,
);
if (isCheckedOut) {
acc.notCheckedInTeams.push(cur);
} else {
acc.checkedInTeams.push(cur);
}
}
return acc;
@ -430,32 +470,48 @@ export class Tournament {
}
bracketSettings(
type: TournamentBracketProgression[number]["type"],
selectedSettings: TournamentStageSettings | null,
type: Tables["TournamentStage"]["type"],
participantsCount: number,
): Stage["settings"] {
switch (type) {
case "single_elimination":
case "single_elimination": {
if (participantsCount < 4) {
return { consolationFinal: false };
}
return { consolationFinal: this.ctx.settings.thirdPlaceMatch ?? true };
case "double_elimination":
return {
consolationFinal:
selectedSettings?.thirdPlaceMatch ??
this.ctx.settings.thirdPlaceMatch ??
true,
};
}
case "double_elimination": {
return {
grandFinal: "double",
};
case "round_robin":
}
case "round_robin": {
const teamsPerGroup =
selectedSettings?.teamsPerGroup ??
this.ctx.settings.teamsPerGroup ??
TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP;
return {
groupCount: Math.ceil(
participantsCount /
(this.ctx.settings.teamsPerGroup ??
TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP),
),
groupCount: Math.ceil(participantsCount / teamsPerGroup),
seedOrdering: ["groups.seed_optimized"],
};
}
case "swiss": {
return {
swiss: this.ctx.settings.swiss,
swiss:
selectedSettings?.groupCount && selectedSettings.roundCount
? {
groupCount: selectedSettings.groupCount,
roundCount: selectedSettings.roundCount,
}
: this.ctx.settings.swiss,
};
}
default: {
@ -591,11 +647,11 @@ export class Tournament {
}
get standings() {
for (const bracket of this.brackets) {
if (bracket.name === BRACKET_NAMES.MAIN) {
return bracket.standings;
}
if (this.brackets.length === 1) {
return this.brackets[0].standings;
}
for (const bracket of this.brackets) {
if (bracket.isFinals) {
const finalsStandings = bracket.standings;

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { removeDuplicates } from "~/utils/arrays";
import type * as Progression from "../Progression";
import { Tournament } from "../Tournament";
import type { TournamentData } from "../Tournament.server";
@ -9,7 +9,7 @@ const tournamentCtxTeam = (
partial?: Partial<TournamentData["ctx"]["teams"][0]>,
): TournamentData["ctx"]["teams"][0] => {
return {
checkIns: [{ checkedInAt: 1705858841, bracketIdx: null }],
checkIns: [{ checkedInAt: 1705858841, bracketIdx: null, isCheckOut: 0 }],
createdAt: 0,
id: teamId,
inviteCode: null,
@ -77,7 +77,12 @@ export const testTournament = ({
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [
{ name: BRACKET_NAMES.MAIN, type: "double_elimination" },
{
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
},
],
},
castedMatchesInfo: null,
@ -143,3 +148,116 @@ export const adjustResults = (
}),
};
};
const DEFAULT_PROGRESSION_ARGS = {
requiresCheckIn: false,
settings: {},
name: "Main Bracket",
};
export const progressions = {
singleElimination: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
},
],
roundRobinToSingleElimination: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "round_robin",
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "B1",
sources: [
{
bracketIdx: 0,
placements: [1, 2],
},
],
},
],
lowInk: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "swiss",
},
{
...DEFAULT_PROGRESSION_ARGS,
name: "B1",
type: "double_elimination",
sources: [
{
bracketIdx: 0,
placements: [3, 4],
},
],
},
{
...DEFAULT_PROGRESSION_ARGS,
name: "B2",
type: "round_robin",
sources: [
{
bracketIdx: 0,
placements: [1, 2],
},
],
},
{
...DEFAULT_PROGRESSION_ARGS,
name: "B3",
type: "double_elimination",
sources: [
{
bracketIdx: 2,
placements: [1, 2],
},
],
},
],
manyStartBrackets: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "round_robin",
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "round_robin",
name: "B1",
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "B2",
sources: [
{
bracketIdx: 0,
placements: [1, 2],
},
],
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "B3",
sources: [
{
bracketIdx: 1,
placements: [1, 2],
},
],
},
],
swissOneGroup: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "swiss",
settings: {
groupCount: 1,
},
},
],
} satisfies Record<string, Progression.ParsedBracket[]>;

View File

@ -2,10 +2,7 @@
import clone from "just-clone";
import shuffle from "just-shuffle";
import type {
TournamentBracketProgression,
TournamentRoundMaps,
} from "~/db/tables";
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import type { Round } from "~/modules/brackets-model";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
@ -24,7 +21,7 @@ export interface GenerateTournamentRoundMaplistArgs {
pool: Array<{ mode: ModeShort; stageId: StageId }>;
rounds: Round[];
mapCounts: BracketMapCounts;
type: TournamentBracketProgression[number]["type"];
type: Tables["TournamentStage"]["type"];
roundsWithPickBan: Set<number>;
pickBanStyle: TournamentRoundMaps["pickBan"];
flavor: "SZ_FIRST" | null;
@ -101,7 +98,7 @@ export function generateTournamentRoundMaplist(
function getFilteredRounds(
rounds: Round[],
type: TournamentBracketProgression[number]["type"],
type: Tables["TournamentStage"]["type"],
) {
if (type !== "round_robin" && type !== "swiss") return rounds;
@ -110,10 +107,7 @@ function getFilteredRounds(
return rounds.filter((x) => x.group_id === highestGroupId);
}
function sortRounds(
rounds: Round[],
type: TournamentBracketProgression[number]["type"],
) {
function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) {
return rounds.slice().sort((a, b) => {
if (type === "double_elimination") {
// grands last
@ -133,7 +127,7 @@ function sortRounds(
function resolveRoundMapCount(
round: Round,
counts: BracketMapCounts,
type: TournamentBracketProgression[number]["type"],
type: Tables["TournamentStage"]["type"],
) {
// with rr/swiss we just take the first group id
// as every group has the same map list

View File

@ -1,6 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import { add } from "date-fns";
import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";
@ -28,13 +29,11 @@ import { currentSeason } from "~/features/mmr/season";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { checkInMany } from "~/features/tournament/queries/checkInMany.server";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix.server";
@ -112,6 +111,7 @@ export const action: ActionFunction = async ({ params, request }) => {
seeding,
tournamentId,
settings: tournament.bracketSettings(
bracket.settings,
bracket.type,
seeding.length,
),
@ -126,6 +126,7 @@ export const action: ActionFunction = async ({ params, request }) => {
? seeding
: fillWithNullTillPowerOfTwo(seeding),
settings: tournament.bracketSettings(
bracket.settings,
bracket.type,
seeding.length,
),
@ -139,32 +140,6 @@ export const action: ActionFunction = async ({ params, request }) => {
bracket,
}),
);
// check in teams to the final stage ahead of time so they don't have to do it
// separately, but also allow for TO's to check them out if needed
if (data.bracketIdx === 0 && tournament.brackets.length > 1) {
const finalStageIdx = tournament.brackets.findIndex(
(b) => b.isFinals,
);
if (finalStageIdx !== -1) {
const allFollowUpBracketIdxs = nullFilledArray(
tournament.brackets.length,
)
.map((_, i) => i)
// filter out groups stage
.filter((i) => i !== 0);
checkInMany({
bracketIdxs: tournament.ctx.settings.autoCheckInAll
? allFollowUpBracketIdxs
: [finalStageIdx],
tournamentTeamIds: tournament.ctx.teams
.filter((t) => t.checkIns.length > 0)
.map((t) => t.id),
});
}
}
})();
break;
@ -483,9 +458,28 @@ export default function TournamentBracketsPage() {
</div>
) : null}
{bracket.sources?.every((s) => !s.placements.includes(1)) &&
!tournament.ctx.settings.autoCheckInAll ? (
bracket.checkInRequired ? (
<div className="text-center text-sm font-semi-bold text-lighter mt-2 text-warning">
Bracket requires check-in
Bracket requires check-in{" "}
{bracket.startTime ? (
<span suppressHydrationWarning>
(open{" "}
{bracket.startTime.toLocaleString("en-US", {
hour: "numeric",
minute: "numeric",
weekday: "long",
})}{" "}
-{" "}
{add(bracket.startTime, { hours: 1 }).toLocaleTimeString(
"en-US",
{
hour: "numeric",
minute: "numeric",
},
)}
)
</span>
) : null}
</div>
) : null}
</div>

View File

@ -2,7 +2,14 @@ import { type Insertable, type NotNull, type Transaction, sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
import type { CastedMatchesInfo, DB, PreparedMaps, Tables } from "~/db/tables";
import type {
CastedMatchesInfo,
DB,
PreparedMaps,
Tables,
TournamentSettings,
} from "~/db/tables";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { Status } from "~/modules/brackets-model";
import { modesShort } from "~/modules/in-game-lists";
import { nullFilledArray } from "~/utils/arrays";
@ -177,6 +184,7 @@ export async function findById(id: number) {
.select([
"TournamentTeamCheckIn.bracketIdx",
"TournamentTeamCheckIn.checkedInAt",
"TournamentTeamCheckIn.isCheckOut",
])
.whereRef(
"TournamentTeamCheckIn.tournamentTeamId",
@ -541,14 +549,27 @@ export function checkIn({
tournamentTeamId: number;
bracketIdx: number | null;
}) {
return db
.insertInto("TournamentTeamCheckIn")
.values({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
})
.execute();
return db.transaction().execute(async (trx) => {
let query = trx
.deleteFrom("TournamentTeamCheckIn")
.where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId)
.where("TournamentTeamCheckIn.isCheckOut", "=", 1);
if (typeof bracketIdx === "number") {
query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx);
}
await query.execute();
await trx
.insertInto("TournamentTeamCheckIn")
.values({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
})
.execute();
});
}
export function checkOut({
@ -558,15 +579,90 @@ export function checkOut({
tournamentTeamId: number;
bracketIdx: number | null;
}) {
let query = db
.deleteFrom("TournamentTeamCheckIn")
.where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId);
return db.transaction().execute(async (trx) => {
let query = trx
.deleteFrom("TournamentTeamCheckIn")
.where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId);
if (typeof bracketIdx === "number") {
query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx);
}
if (typeof bracketIdx === "number") {
query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx);
}
return query.execute();
await query.execute();
if (typeof bracketIdx === "number") {
await trx
.insertInto("TournamentTeamCheckIn")
.values({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
isCheckOut: 1,
})
.execute();
}
});
}
export function updateProgression({
tournamentId,
bracketProgression,
}: {
tournamentId: number;
bracketProgression: TournamentSettings["bracketProgression"];
}) {
return db.transaction().execute(async (trx) => {
const { settings: existingSettings } = await trx
.selectFrom("Tournament")
.select("settings")
.where("id", "=", tournamentId)
.executeTakeFirstOrThrow();
if (
Progression.changedBracketProgressionFormat(
existingSettings.bracketProgression,
bracketProgression,
)
) {
const allTournamentTeamsOfTournament = (
await trx
.selectFrom("TournamentTeam")
.select("id")
.where("tournamentId", "=", tournamentId)
.execute()
).map((t) => t.id);
// delete all bracket check-ins
await trx
.deleteFrom("TournamentTeamCheckIn")
.where("TournamentTeamCheckIn.bracketIdx", "is not", null)
.where(
"TournamentTeamCheckIn.tournamentTeamId",
"in",
allTournamentTeamsOfTournament,
)
.execute();
}
const newSettings: Tables["Tournament"]["settings"] = {
...existingSettings,
bracketProgression,
};
await trx
.updateTable("Tournament")
.set({
settings: JSON.stringify(newSettings),
preparedMaps: Progression.changedBracketProgressionFormat(
existingSettings.bracketProgression,
bracketProgression,
)
? null
: undefined,
})
.where("id", "=", tournamentId)
.execute();
});
}
export function updateTeamName({

View File

@ -0,0 +1,472 @@
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod";
import { requireUserId } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
badRequestIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { USER } from "../../../constants";
import { _action, id } from "../../../utils/zod";
import { bracketProgressionSchema } from "../../calendar/actions/calendar.new.server";
import { bracketIdx } from "../../tournament-bracket/tournament-bracket-schemas.server";
import * as TournamentRepository from "../TournamentRepository.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { teamName } from "../tournament-schemas.server";
import { tournamentIdFromParams } from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: adminActionSchema,
});
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
const validateIsTournamentAdmin = () =>
validate(tournament.isAdmin(user), "Unauthorized", 401);
const validateIsTournamentOrganizer = () =>
validate(tournament.isOrganizer(user), "Unauthorized", 401);
switch (data._action) {
case "ADD_TEAM": {
validateIsTournamentOrganizer();
validate(
tournament.ctx.teams.every((t) => t.name !== data.teamName),
"Team name taken",
);
validate(
!tournament.teamMemberOfByUser({ id: data.userId }),
"User already on a team",
);
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
team: {
name: data.teamName,
noScreen: 0,
prefersNotToHost: 0,
teamId: null,
},
userId: data.userId,
tournamentId,
});
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.userId,
});
break;
}
case "CHANGE_TEAM_OWNER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
const oldCaptain = team.members.find((m) => m.isOwner);
invariant(oldCaptain, "Team has no captain");
const newCaptain = team.members.find((m) => m.userId === data.memberId);
validate(newCaptain, "Invalid member id");
changeTeamOwner({
newCaptainId: data.memberId,
oldCaptainId: oldCaptain.userId,
tournamentTeamId: data.teamId,
});
break;
}
case "CHANGE_TEAM_NAME": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
await TournamentRepository.updateTeamName({
tournamentTeamId: data.teamId,
name: data.teamName,
});
break;
}
case "CHECK_IN": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
data.bracketIdx !== 0 ||
tournament.checkInConditionsFulfilledByTeamId(team.id),
"Can't check-in",
);
validate(
team.checkIns.length > 0 || data.bracketIdx === 0,
"Can't check-in to follow up bracket if not checked in for the event itself",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
await TournamentRepository.checkIn({
tournamentTeamId: data.teamId,
// no sources = regular check in
bracketIdx: !bracket.sources ? null : data.bracketIdx,
});
break;
}
case "CHECK_OUT": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
data.bracketIdx !== 0 || !tournament.hasStarted,
"Tournament has started",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
await TournamentRepository.checkOut({
tournamentTeamId: data.teamId,
// no sources = regular check in
bracketIdx: !bracket.sources ? null : data.bracketIdx,
});
logger.info(
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
);
break;
}
case "REMOVE_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
team.checkIns.length === 0 || team.members.length > 4,
"Can't remove last member from checked in team",
);
validate(
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
"Cannot remove team owner",
);
validate(
!tournament.hasStarted ||
!tournament
.participatedPlayersByTeamId(data.teamId)
.some((p) => p.userId === data.memberId),
"Cannot remove player that has participated in the tournament",
);
leaveTeam({
userId: data.memberId,
teamId: team.id,
});
ShowcaseTournaments.removeFromParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.memberId,
});
break;
}
case "ADD_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
const previousTeam = tournament.teamMemberOfByUser({ id: data.userId });
if (tournament.hasStarted) {
validate(
!previousTeam || previousTeam.checkIns.length === 0,
"User is already on a checked in team",
);
} else {
validate(!previousTeam, "User is already on a team");
}
validate(
!userIsBanned(data.userId),
"User trying to be added currently has an active ban from sendou.ink",
);
joinTeam({
userId: data.userId,
newTeamId: team.id,
previousTeamId: previousTeam?.id,
// this team is not checked in so we can simply delete it
whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.userId,
});
break;
}
case "DELETE_TEAM": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!tournament.hasStarted, "Tournament has started");
deleteTeam(team.id);
ShowcaseTournaments.clearParticipationInfoMap();
break;
}
case "ADD_STAFF": {
validateIsTournamentAdmin();
await TournamentRepository.addStaff({
role: data.role,
tournamentId: tournament.ctx.id,
userId: data.userId,
});
if (data.role === "ORGANIZER") {
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "organizer",
userId: data.userId,
});
}
break;
}
case "REMOVE_STAFF": {
validateIsTournamentAdmin();
await TournamentRepository.removeStaff({
tournamentId: tournament.ctx.id,
userId: data.userId,
});
ShowcaseTournaments.removeFromParticipationInfoMap({
tournamentId,
type: "organizer",
userId: data.userId,
});
break;
}
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
validateIsTournamentOrganizer();
await TournamentRepository.updateCastTwitchAccounts({
tournamentId: tournament.ctx.id,
castTwitchAccounts: data.castTwitchAccounts,
});
break;
}
case "DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.dropTeamOut({
tournamentTeamId: data.teamId,
previewBracketIdxs: tournament.brackets.flatMap((b, idx) =>
b.preview ? idx : [],
),
});
break;
}
case "UNDO_DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.undoDropTeamOut(data.teamId);
break;
}
case "RESET_BRACKET": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
const bracketToResetIdx = tournament.brackets.findIndex(
(b) => b.id === data.stageId,
);
const bracketToReset = tournament.brackets[bracketToResetIdx];
validate(bracketToReset, "Invalid bracket id");
validate(!bracketToReset.preview, "Bracket has not started");
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
validate(
inProgressBrackets.every(
(b) =>
!b.sources ||
b.sources.every((s) => s.bracketIdx !== bracketToResetIdx),
),
"Some bracket that sources teams from this bracket has started",
);
await TournamentRepository.resetBracket(data.stageId);
break;
}
case "UPDATE_IN_GAME_NAME": {
validateIsTournamentOrganizer();
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: data.memberId }),
);
await TournamentTeamRepository.updateMemberInGameName({
userId: data.memberId,
inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`,
tournamentTeamId: teamMemberOf.id,
});
break;
}
case "DELETE_LOGO": {
validateIsTournamentOrganizer();
await TournamentTeamRepository.deleteLogo(data.teamId);
break;
}
case "UPDATE_TOURNAMENT_PROGRESSION": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
validate(
Progression.changedBracketProgression(
tournament.ctx.settings.bracketProgression,
data.bracketProgression,
).every(
(changedBracketIdx) =>
tournament.bracketByIdx(changedBracketIdx)?.preview,
),
"Can't change started brackets",
);
await TournamentRepository.updateProgression({
tournamentId: tournament.ctx.id,
bracketProgression: data.bracketProgression,
});
break;
}
default: {
assertUnreachable(data);
}
}
clearTournamentDataCache(tournamentId);
return null;
};
export const adminActionSchema = z.union([
z.object({
_action: _action("CHANGE_TEAM_OWNER"),
teamId: id,
memberId: id,
}),
z.object({
_action: _action("CHANGE_TEAM_NAME"),
teamId: id,
teamName,
}),
z.object({
_action: _action("CHECK_IN"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("CHECK_OUT"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("ADD_MEMBER"),
teamId: id,
userId: id,
}),
z.object({
_action: _action("REMOVE_MEMBER"),
teamId: id,
memberId: id,
}),
z.object({
_action: _action("DELETE_TEAM"),
teamId: id,
}),
z.object({
_action: _action("ADD_TEAM"),
userId: id,
teamName,
}),
z.object({
_action: _action("ADD_STAFF"),
userId: id,
role: z.enum(["ORGANIZER", "STREAMER"]),
}),
z.object({
_action: _action("REMOVE_STAFF"),
userId: id,
}),
z.object({
_action: _action("DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("UNDO_DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("DELETE_LOGO"),
teamId: id,
}),
z.object({
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
castTwitchAccounts: z.preprocess(
(val) =>
typeof val === "string"
? val
.split(",")
.map((account) => account.trim())
.map((account) => account.toLowerCase())
: val,
z.array(z.string()),
),
}),
z.object({
_action: _action("RESET_BRACKET"),
stageId: id,
}),
z.object({
_action: _action("UPDATE_IN_GAME_NAME"),
inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH),
inGameNameDiscriminator: z
.string()
.refine((val) => /^[0-9a-z]{4,5}$/.test(val)),
memberId: id,
}),
z.object({
_action: _action("UPDATE_TOURNAMENT_PROGRESSION"),
bracketProgression: bracketProgressionSchema,
}),
]);

View File

@ -1,25 +0,0 @@
import { sql } from "~/db/sql";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const stm = sql.prepare(/*sql*/ `
insert into "TournamentTeamCheckIn" ("checkedInAt", "tournamentTeamId", "bracketIdx")
values (@checkedInAt, @tournamentTeamId, @bracketIdx)
`);
export function checkInMany({
tournamentTeamIds,
bracketIdxs,
}: {
tournamentTeamIds: number[];
bracketIdxs: number[];
}) {
for (const bracketIdx of bracketIdxs) {
for (const tournamentTeamId of tournamentTeamIds) {
stm.run({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
});
}
}
}

View File

@ -1,4 +1,3 @@
import type { ActionFunction } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
@ -17,373 +16,35 @@ import { UserSearch } from "~/components/UserSearch";
import { TrashIcon } from "~/components/icons/Trash";
import { USER } from "~/constants";
import { useUser } from "~/features/auth/core/user";
import { requireUserId } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
badRequestIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
calendarEventPage,
tournamentEditPage,
tournamentPage,
} from "~/utils/urls";
import * as TournamentRepository from "../TournamentRepository.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { adminActionSchema } from "../tournament-schemas.server";
import { tournamentIdFromParams } from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import { Dialog } from "../../../components/Dialog";
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
import { useTournament } from "./to.$id";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: adminActionSchema,
});
import { action } from "../actions/to.$id.admin.server";
export { action };
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
const validateIsTournamentAdmin = () =>
validate(tournament.isAdmin(user), "Unauthorized", 401);
const validateIsTournamentOrganizer = () =>
validate(tournament.isOrganizer(user), "Unauthorized", 401);
switch (data._action) {
case "ADD_TEAM": {
validateIsTournamentOrganizer();
validate(
tournament.ctx.teams.every((t) => t.name !== data.teamName),
"Team name taken",
);
validate(
!tournament.teamMemberOfByUser({ id: data.userId }),
"User already on a team",
);
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
team: {
name: data.teamName,
noScreen: 0,
prefersNotToHost: 0,
teamId: null,
},
userId: data.userId,
tournamentId,
});
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.userId,
});
break;
}
case "CHANGE_TEAM_OWNER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
const oldCaptain = team.members.find((m) => m.isOwner);
invariant(oldCaptain, "Team has no captain");
const newCaptain = team.members.find((m) => m.userId === data.memberId);
validate(newCaptain, "Invalid member id");
changeTeamOwner({
newCaptainId: data.memberId,
oldCaptainId: oldCaptain.userId,
tournamentTeamId: data.teamId,
});
break;
}
case "CHANGE_TEAM_NAME": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
await TournamentRepository.updateTeamName({
tournamentTeamId: data.teamId,
name: data.teamName,
});
break;
}
case "CHECK_IN": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
data.bracketIdx !== 0 ||
tournament.checkInConditionsFulfilledByTeamId(team.id),
"Can't check-in",
);
validate(
team.checkIns.length > 0 || data.bracketIdx === 0,
"Can't check-in to follow up bracket if not checked in for the event itself",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
await TournamentRepository.checkIn({
tournamentTeamId: data.teamId,
// 0 = regular check in
bracketIdx: data.bracketIdx === 0 ? null : data.bracketIdx,
});
break;
}
case "CHECK_OUT": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
data.bracketIdx !== 0 || !tournament.hasStarted,
"Tournament has started",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
await TournamentRepository.checkOut({
tournamentTeamId: data.teamId,
// 0 = regular check in
bracketIdx: data.bracketIdx === 0 ? null : data.bracketIdx,
});
logger.info(
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
);
break;
}
case "REMOVE_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
team.checkIns.length === 0 || team.members.length > 4,
"Can't remove last member from checked in team",
);
validate(
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
"Cannot remove team owner",
);
validate(
!tournament.hasStarted ||
!tournament
.participatedPlayersByTeamId(data.teamId)
.some((p) => p.userId === data.memberId),
"Cannot remove player that has participated in the tournament",
);
leaveTeam({
userId: data.memberId,
teamId: team.id,
});
ShowcaseTournaments.removeFromParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.memberId,
});
break;
}
case "ADD_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
const previousTeam = tournament.teamMemberOfByUser({ id: data.userId });
if (tournament.hasStarted) {
validate(
!previousTeam || previousTeam.checkIns.length === 0,
"User is already on a checked in team",
);
} else {
validate(!previousTeam, "User is already on a team");
}
validate(
!userIsBanned(data.userId),
"User trying to be added currently has an active ban from sendou.ink",
);
joinTeam({
userId: data.userId,
newTeamId: team.id,
previousTeamId: previousTeam?.id,
// this team is not checked in so we can simply delete it
whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "participant",
userId: data.userId,
});
break;
}
case "DELETE_TEAM": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!tournament.hasStarted, "Tournament has started");
deleteTeam(team.id);
ShowcaseTournaments.clearParticipationInfoMap();
break;
}
case "ADD_STAFF": {
validateIsTournamentAdmin();
await TournamentRepository.addStaff({
role: data.role,
tournamentId: tournament.ctx.id,
userId: data.userId,
});
if (data.role === "ORGANIZER") {
ShowcaseTournaments.addToParticipationInfoMap({
tournamentId,
type: "organizer",
userId: data.userId,
});
}
break;
}
case "REMOVE_STAFF": {
validateIsTournamentAdmin();
await TournamentRepository.removeStaff({
tournamentId: tournament.ctx.id,
userId: data.userId,
});
ShowcaseTournaments.removeFromParticipationInfoMap({
tournamentId,
type: "organizer",
userId: data.userId,
});
break;
}
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
validateIsTournamentOrganizer();
await TournamentRepository.updateCastTwitchAccounts({
tournamentId: tournament.ctx.id,
castTwitchAccounts: data.castTwitchAccounts,
});
break;
}
case "DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.dropTeamOut({
tournamentTeamId: data.teamId,
previewBracketIdxs: tournament.brackets.flatMap((b, idx) =>
b.preview ? idx : [],
),
});
break;
}
case "UNDO_DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.undoDropTeamOut(data.teamId);
break;
}
case "RESET_BRACKET": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
const bracketToResetIdx = tournament.brackets.findIndex(
(b) => b.id === data.stageId,
);
const bracketToReset = tournament.brackets[bracketToResetIdx];
validate(bracketToReset, "Invalid bracket id");
validate(!bracketToReset.preview, "Bracket has not started");
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
validate(
inProgressBrackets.every(
(b) =>
!b.sources ||
b.sources.every((s) => s.bracketIdx !== bracketToResetIdx),
),
"Some bracket that sources teams from this bracket has started",
);
await TournamentRepository.resetBracket(data.stageId);
break;
}
case "UPDATE_IN_GAME_NAME": {
validateIsTournamentOrganizer();
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: data.memberId }),
);
await TournamentTeamRepository.updateMemberInGameName({
userId: data.memberId,
inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`,
tournamentTeamId: teamMemberOf.id,
});
break;
}
case "DELETE_LOGO": {
validateIsTournamentOrganizer();
await TournamentTeamRepository.deleteLogo(data.teamId);
break;
}
default: {
assertUnreachable(data);
}
}
clearTournamentDataCache(tournamentId);
return null;
};
// TODO: translations
export default function TournamentAdminPage() {
const { t } = useTranslation(["calendar"]);
const tournament = useTournament();
const [editingProgression, setEditingProgression] = React.useState(false);
const user = useUser();
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to close the dialog after the progression was updated
React.useEffect(() => {
setEditingProgression(false);
}, [tournament]);
if (!tournament.isOrganizer(user) || tournament.everyBracketOver) {
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
}
@ -418,6 +79,25 @@ export default function TournamentAdminPage() {
</FormWithConfirm>
</div>
) : null}
{tournament.isAdmin(user) &&
tournament.hasStarted &&
!tournament.ctx.isFinalized ? (
<div className="stack horizontal justify-end">
<Button
onClick={() => setEditingProgression(true)}
size="tiny"
variant="outlined"
testId="edit-event-info-button"
>
Edit brackets
</Button>
{editingProgression ? (
<BracketProgressionEditDialog
close={() => setEditingProgression(false)}
/>
) : null}
</div>
) : null}
<Divider smallText>Team actions</Divider>
<TeamActions />
{tournament.isAdmin(user) ? (
@ -1037,3 +717,42 @@ function BracketReset() {
</div>
);
}
function BracketProgressionEditDialog({ close }: { close: () => void }) {
const tournament = useTournament();
const fetcher = useFetcher();
const [bracketProgressionErrored, setBracketProgressionErrored] =
React.useState(false);
const disabledBracketIdxs = tournament.brackets
.filter((bracket) => !bracket.preview)
.map((bracket) => bracket.idx);
return (
<Dialog isOpen className="w-max">
<fetcher.Form method="post">
<BracketProgressionSelector
initialBrackets={Progression.validatedBracketsToInputFormat(
tournament.ctx.settings.bracketProgression,
).map((bracket, idx) => ({
...bracket,
disabled: disabledBracketIdxs.includes(idx),
}))}
isInvitationalTournament={tournament.isInvitational}
setErrored={setBracketProgressionErrored}
/>
<div className="stack md horizontal justify-center mt-6">
<SubmitButton
_action="UPDATE_TOURNAMENT_PROGRESSION"
disabled={bracketProgressionErrored}
>
Save changes
</SubmitButton>
<Button variant="destructive" onClick={close}>
Cancel
</Button>
</div>
</fetcher.Form>
</Dialog>
);
}

View File

@ -3,6 +3,7 @@ import { useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Redirect } from "~/components/Redirect";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { tournamentRegisterPage } from "~/utils/urls";
import { TournamentStream } from "../components/TournamentStream";
import { streamsByTournamentId } from "../core/streams.server";
@ -13,7 +14,7 @@ export type TournamentStreamsLoader = typeof loader;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentData({ tournamentId });
const tournament = notFoundIfFalsy(await tournamentData({ tournamentId }));
return {
streams: await streamsByTournamentId(tournament.ctx),

View File

@ -7,22 +7,10 @@ export const TOURNAMENT = {
ENOUGH_TEAMS_TO_START: 2,
MIN_GROUP_SIZE: 3,
MAX_GROUP_SIZE: 6,
MAX_BRACKETS_PER_TOURNAMENT: 10,
BRACKET_NAME_MAX_LENGTH: 32,
// just a fallback, normally this should be set by user explicitly
DEFAULT_TEAM_COUNT_PER_RR_GROUP: 4,
SWISS_DEFAULT_GROUP_COUNT: 1,
SWISS_DEFAULT_ROUND_COUNT: 5,
} as const;
export const BRACKET_NAMES = {
UNDERGROUND: "Underground bracket",
MAIN: "Main bracket",
GROUPS: "Group stage",
FINALS: "Final stage",
};
export const FORMATS_SHORT = [
"DE",
"SE",
"RR_TO_SE",
"SWISS",
"SWISS_TO_SE",
] as const;
export type TournamentFormatShort = (typeof FORMATS_SHORT)[number];

View File

@ -1,5 +1,4 @@
import { z } from "zod";
import { USER } from "~/constants";
import {
_action,
checkboxValueToBoolean,
@ -9,10 +8,13 @@ import {
safeJSONParse,
stageId,
} from "~/utils/zod";
import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server";
import { TOURNAMENT } from "./tournament-constants";
const teamName = z.string().trim().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH);
export const teamName = z
.string()
.trim()
.min(1)
.max(TOURNAMENT.TEAM_NAME_MAX_LENGTH);
export const registerSchema = z.union([
z.object({
@ -55,94 +57,6 @@ export const seedsActionSchema = z.object({
seeds: z.preprocess(safeJSONParse, z.array(id)),
});
export const adminActionSchema = z.union([
z.object({
_action: _action("CHANGE_TEAM_OWNER"),
teamId: id,
memberId: id,
}),
z.object({
_action: _action("CHANGE_TEAM_NAME"),
teamId: id,
teamName,
}),
z.object({
_action: _action("CHECK_IN"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("CHECK_OUT"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("ADD_MEMBER"),
teamId: id,
userId: id,
}),
z.object({
_action: _action("REMOVE_MEMBER"),
teamId: id,
memberId: id,
}),
z.object({
_action: _action("DELETE_TEAM"),
teamId: id,
}),
z.object({
_action: _action("ADD_TEAM"),
userId: id,
teamName,
}),
z.object({
_action: _action("ADD_STAFF"),
userId: id,
role: z.enum(["ORGANIZER", "STREAMER"]),
}),
z.object({
_action: _action("REMOVE_STAFF"),
userId: id,
}),
z.object({
_action: _action("DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("UNDO_DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("DELETE_LOGO"),
teamId: id,
}),
z.object({
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
castTwitchAccounts: z.preprocess(
(val) =>
typeof val === "string"
? val
.split(",")
.map((account) => account.trim())
.map((account) => account.toLowerCase())
: val,
z.array(z.string()),
),
}),
z.object({
_action: _action("RESET_BRACKET"),
stageId: id,
}),
z.object({
_action: _action("UPDATE_IN_GAME_NAME"),
inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH),
inGameNameDiscriminator: z
.string()
.refine((val) => /^[0-9a-z]{4,5}$/.test(val)),
memberId: id,
}),
]);
export const joinSchema = z.object({
trust: z.preprocess(checkboxValueToBoolean, z.boolean()),
});

View File

@ -1911,3 +1911,15 @@ html[dir="rtl"] .fix-rtl {
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
}
.format-selector__count {
color: var(--theme);
font-size: var(--fonts-sm);
white-space: nowrap;
}
.format-selector__divider {
background-color: var(--theme-transparent);
width: 2px;
align-self: stretch;
}

View File

@ -55,6 +55,9 @@ export const SENDOU_INK_BASE_URL = "https://sendou.ink";
export const BADGES_DOC_LINK =
"https://github.com/Sendouc/sendou.ink/blob/rewrite/docs/badges.md";
export const CREATING_TOURNAMENT_DOC_LINK =
"https://github.com/Sendouc/sendou.ink/blob/rewrite/docs/tournament-creation.md";
const USER_SUBMITTED_IMAGE_ROOT =
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
export const userSubmittedImage = (fileName: string) =>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

145
docs/tournament-creation.md Normal file
View File

@ -0,0 +1,145 @@
# Creating a tournament
## About
Sendou.ink can used to run Splatoon 3 tournaments without the need of another bracket hosting website. Currently it is in limited beta. You can request access via our Discord if you are an established tournament organizer.
## Creating
Tournaments can be created via the add menu on the top right of your screen after logging in assuming you have access:
![alt text](./img/tournament-creation-add.png)
## Fields
This section explains all the different options when you are creating a tournament and what they do.
### Name
Name of the tournament.
### Description
Description of the tournament, shown when registering. Supports Markdown including embedding images.
### Rules
Rules of the tournament. Supports Markdown including embedding images.
### Dates
When tournament starts. Note that unlike calendar events, tournaments can only have one actual starting time.
### Discord server invite URL
Invite link URL to your tournament's Discord server.
### Tags
Tags that apply to your tournament. Please take a look at the selection and choose all that apply.
### Logo
Tournament logo you can upload to be shown in various places.
### Players count
Choose whether you want to host a regular 4v4 tournmament or 3v3/2v2/1v1 tournament.
### Registration closes at
Choose relative to the tournament start time when sign ups close. When the registration closes new teams can't sign up, add team members, change their registration info and new users can't join the list of subs. Everything but the last is possible via admin actions regardless of whether the registration is open or not.
### Ranked
Host the event as ranked or not. If there is a ranked season open on the site then ranked tournaments contribute to the seasonal rankings. Some events are not allowed to be run as ranked:
- Gimmick rules (some weapon restrictions is fine for example "no duplicate specials")
- 3v3/2v2/1v1
- Skill capped in any way
If you are not sure whether your event qualifies to be ran as ranked, ask before hosting.
### Autonomous subs
Allow teams to add subs while the tournament is in progress on their own. If off then all the subs have to be added by the tournament organizers.
![alt text](./img/tournament-auto-subs.png)
*Tournament team member adding a sub in the middle of a tournament*
### Require in-game names
Especially for tournaments where verification is important. Players need to have submit an in-game name (e.g. Sendou#1234) and this can't be changed after registration closes.
### Invitational
All teams added by the tournament organizer manually. No open registration or subs list. In addition for invitational teams can add only 5 members before the tournament starts on their own (and 6 during it if autonomous subs are enabled).
### Strict deadlines
Display the "deadline" for each round as 5 minutes stricter. Note that this is only visual and it's up to the tournament organizer how to enforce these if at all.
## Tournament maps
With sendou.ink tournaments all maps are decided ahead of time.
### Prepicked by teams
Map pool is always the same as current SendouQ seasonal map pool in terms of bans.
For SZ/TC/RM/CB only no maps are picked by the tournament organizer.
For all modes the tournament organizer picks one tiebreaker map per mode.
![alt text](./img/tournament-team-map-pick.png)
*Team picking maps as part of their registration process*
Then when the tournament in in progress an algorithm decides the map list for each match:
![alt text](./img/tournament-map-list-algo.png)
[More info on how it works](https://gist.github.com/Sendouc/285c697ad98171243bf5c08a4c7e1f30).
### Picked by TO
Note that here you select just the map pool. The actual map lists are picked when the bracket starts (or prepared) in advance:
![alt text](./img/tournament-bracket-start.png)
*View when starting bracket*
## Tournament format
Choose the tournament format. You can have at most 10 brackets with teams advancing between them as you wish.
Source bracket means a bracket where teams come from. Target bracket means a bracket where teams go to after first playing some other bracket. A bracket can be both at the same time.
### Placements
Placements is a comma separated list of placements. So e.g. the following are valid:
- `1,2,3`
- `1-3`
- `-1,-2`
Placements are relative in the sense that the amount of teams that sign up don't affect them. `1` is always the 1st placement but `2` is the "2nd best possible placement to achieve" and so on. So for example with round robin the amount of teams advancing from that bracket depends entirely on the amount of groups (which is decided via sign ups.)
![alt text](./img/tournament-placement-mapping.png)
*A screenshot from one Swim or Sink and how the placements map*
### Start time
Whether to start the bracket right after the previous one concludes or at some other time. This can be useful for two day tournaments. Note that it's not really meant to organize an event that spans many weeks (organization page features can be used instead).
### Check-in required
Whether to require check-in to the bracket or not. Note even if you leave it off, you can still check out teams.
### Limitations
Current limitations. Feel free to leave feedback if it's blocking you from running some event you wish:
- Single-elimination can not be a source bracket
- Double-elimination can only be a source bracket when it comes to people who drop in the losers round (negative placements)
- All teams start the tournament in the same bracket
- Only one source bracket per target bracket.

View File

@ -459,9 +459,8 @@ test.describe("Tournament bracket", () => {
});
await page.getByTestId("edit-event-info-button").click();
await page.getByLabel("Auto check-in to follow-up brackets").check();
await page.getByTestId("remove-bracket").click();
await page.getByTestId("placement-3-4").click();
await page.getByTestId("delete-bracket-button").last().click();
await page.getByTestId("placements-input").last().fill("3,4");
await submit(page);

View File

@ -143,5 +143,16 @@
"subs.weapons.info": "Choose between {{min}} and {{max}}",
"subs.message.header": "Message",
"subs.visibility.header": "Visibility",
"subs.visibility.everyone": "Everyone"
"subs.visibility.everyone": "Everyone",
"progression.error.PLACEMENTS_PARSE_ERROR": "Error parsing placements",
"progression.error.NOT_RESOLVING_WINNER": "Progression does not resolve winner",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "Same placement leads to multiple brackets",
"progression.error.GAP_IN_PLACEMENTS": "Gap in placements that advance",
"progression.error.TOO_MANY_PLACEMENTS": "Too many placements (more than teams in groups)",
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
"progression.error.NAME_MISSING": "Bracket name missing",
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression"
}

View File

@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentTeamCheckIn" add "isCheckOut" integer default 0`,
).run();
})();
}