Tournament groups->SE, underground bracket etc. (#1628)

* Renders groups

* Bracket data refactoring

* Starting bracket working (first bracket only)

* TODOs + crash fix

* Source bracket logic initial

* Bracket progression (DE underground bracket)

* Preview working for second bracket

* Bracket nav initial

* Check-in to bracket feature

* Start Underground bracket

* Team/teams pages tweaks to support underground bracket

* Underground bracket finalization progress

* Tournament class

* id -> userId + more useOutletContext removed

* Bracket loader refactored out

* Migrate admin to useTournament

* Bracket.settings

* Slim tournament loader

* Fix useEffect infinite loop

* Adjust waiting for teams text

* Refactor old tournament DB call from to admin

* Admin action: check in/out from specific bracket

* Standings work

* Back button from match page -> correct bracket

* Standings logic for DE grand finals

* Standings + finalize bracket

* Dev log

* Unit tests utils etc.

* Adjust TODOs

* Fix round robin issues

* Add RR tests

* Round robin standings initial

* Wins against tied + points tiebreaker progress

* Fix losing state when switching between tabs

* Add check-in indications to seeding page

* Link to user page on seed tool

* Submit points

* Total points from bracket manager

* findById gonezino

* Ahead of time check-in

* Couple todos

* Reopen logic refactor

* Tournament format settings

* RR->SE placements, skipping underground bracket

* Fix tournament team page round names

* More teams to UG bracket if first round of DE only byes

* Fix graphics bug

* Fixes

* Fix some E2E tests

* Fix E2E tests
This commit is contained in:
Kalle 2024-01-30 00:32:13 +02:00 committed by GitHub
parent 944dddae51
commit 144da5d158
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 4584 additions and 2016 deletions

View File

@ -14,6 +14,7 @@ type LabelProps = Pick<
required?: boolean;
className?: string;
labelClassName?: string;
spaced?: boolean;
};
export function Label({
@ -23,9 +24,10 @@ export function Label({
htmlFor,
className,
labelClassName,
spaced = true,
}: LabelProps) {
return (
<div className={clsx("label__container", className)}>
<div className={clsx("label__container", className, { "mb-0": !spaced })}>
<label htmlFor={htmlFor} className={labelClassName}>
{children} {required && <span className="text-error">*</span>}
</label>

View File

@ -58,7 +58,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { nullFilledArray, pickRandomItem } from "~/utils/arrays";
import type { UserMapModePreferences } from "../tables";
import type { Tables, UserMapModePreferences } from "../tables";
import type { Art, UserSubmittedImage } from "../types";
import {
ADMIN_TEST_AVATAR,
@ -69,9 +69,16 @@ import {
} from "./constants";
import placements from "./placements.json";
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
const calendarEventWithToToolsRegOpen = () =>
calendarEventWithToTools("PICNIC", true);
const calendarEventWithToToolsSz = () => calendarEventWithToTools("ITZ");
const calendarEventWithToToolsTeamsSz = () =>
calendarEventWithToToolsTeams(true);
calendarEventWithToToolsTeams("ITZ");
const calendarEventWithToToolsPP = () => calendarEventWithToTools("PP");
const calendarEventWithToToolsPPRegOpen = () =>
calendarEventWithToTools("PP", true);
const calendarEventWithToToolsTeamsPP = () =>
calendarEventWithToToolsTeams("PP");
const basicSeeds = (variation?: SeedVariation | null) => [
adminUser,
@ -95,15 +102,23 @@ const basicSeeds = (variation?: SeedVariation | null) => [
calendarEvents,
calendarEventBadges,
calendarEventResults,
calendarEventWithToTools,
variation === "REG_OPEN"
? calendarEventWithToToolsRegOpen
: calendarEventWithToTools,
calendarEventWithToToolsTieBreakerMapPool,
variation === "NO_TOURNAMENT_TEAMS"
variation === "NO_TOURNAMENT_TEAMS" || variation === "REG_OPEN"
? undefined
: calendarEventWithToToolsTeams,
variation === "NO_TOURNAMENT_TEAMS" ? undefined : calendarEventWithToToolsSz,
calendarEventWithToToolsSz,
variation === "NO_TOURNAMENT_TEAMS"
? undefined
: calendarEventWithToToolsTeamsSz,
variation === "REG_OPEN"
? calendarEventWithToToolsPPRegOpen
: calendarEventWithToToolsPP,
variation === "NO_TOURNAMENT_TEAMS"
? undefined
: calendarEventWithToToolsTeamsPP,
tournamentSubs,
adminBuilds,
manySplattershotBuilds,
@ -809,9 +824,59 @@ async function calendarEventResults() {
}
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
function calendarEventWithToTools(sz?: boolean) {
const tournamentId = sz ? 2 : 1;
const eventId = TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0);
function calendarEventWithToTools(
event: "PICNIC" | "ITZ" | "PP" = "PICNIC",
registrationOpen: boolean = false,
) {
const tournamentId = {
PICNIC: 1,
ITZ: 2,
PP: 3,
}[event];
const eventId = {
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
ITZ: TO_TOOLS_CALENDAR_EVENT_ID + 1,
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
}[event];
const name = {
PICNIC: "PICNIC #2",
ITZ: "In The Zone 22",
PP: "Paddling Pool 253",
}[event];
const settings: Tables["Tournament"]["settings"] =
event === "PP"
? {
bracketProgression: [
{ type: "round_robin", name: "Groups stage" },
{
type: "single_elimination",
name: "Final stage",
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
{
type: "single_elimination",
name: "Underground bracket",
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
],
}
: event === "ITZ"
? {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
{
type: "single_elimination",
name: "Underground bracket",
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
},
],
}
: {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
],
};
sql
.prepare(
@ -819,18 +884,18 @@ function calendarEventWithToTools(sz?: boolean) {
insert into "Tournament" (
"id",
"mapPickingStyle",
"format"
"settings"
) values (
$id,
$mapPickingStyle,
$format
$settings
) returning *
`,
)
.run({
id: tournamentId,
format: "DE",
mapPickingStyle: sz ? "AUTO_SZ" : "AUTO_ALL",
settings: JSON.stringify(settings),
mapPickingStyle: event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL",
});
sql
@ -857,7 +922,7 @@ function calendarEventWithToTools(sz?: boolean) {
)
.run({
id: eventId,
name: sz ? "In The Zone 22" : "PICNIC #2",
name,
description: faker.lorem.paragraph(),
discordInviteCode: faker.lorem.word(),
bracketUrl: faker.internet.url(),
@ -865,6 +930,8 @@ function calendarEventWithToTools(sz?: boolean) {
tournamentId,
});
const halfAnHourFromNow = new Date(Date.now() + 1000 * 60 * 30);
sql
.prepare(
`
@ -879,7 +946,11 @@ function calendarEventWithToTools(sz?: boolean) {
)
.run({
eventId,
startTime: dateToDatabaseTimestamp(new Date(Date.now() + 1000 * 60 * 60)),
startTime: dateToDatabaseTimestamp(
registrationOpen
? halfAnHourFromNow
: new Date(Date.now() - 1000 * 60 * 60),
),
});
}
@ -926,14 +997,28 @@ const availablePairs = rankedModesShort
availableStages.map((stageId) => ({ mode, stageId: stageId })),
)
.filter((pair) => !tiebreakerPicks.has(pair));
function calendarEventWithToToolsTeams(sz?: boolean) {
function calendarEventWithToToolsTeams(
event: "PICNIC" | "ITZ" | "PP" = "PICNIC",
) {
const userIds = userIdsInAscendingOrderById();
const names = Array.from(
new Set(new Array(100).fill(null).map(() => validTournamentTeamName())),
).concat("Chimera");
const tournamentId = {
PICNIC: 1,
ITZ: 2,
PP: 3,
}[event];
const teamIdAddition = {
PICNIC: 0,
ITZ: 100,
PP: 200,
}[event];
for (let id = 1; id <= 16; id++) {
const teamId = id + (sz ? 100 : 0);
const teamId = id + teamIdAddition;
const name = names.pop();
invariant(name, "tournament team name is falsy");
@ -960,11 +1045,12 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
id: teamId,
name,
createdAt: dateToDatabaseTimestamp(new Date()),
tournamentId: sz ? 2 : 1,
tournamentId,
inviteCode: nanoid(INVITE_CODE_LENGTH),
});
if (sz || id !== 1) {
// in PICNIC & PP Chimera is not checked in
if (teamId !== 1 && teamId !== 201) {
sql
.prepare(
`
@ -978,7 +1064,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
`,
)
.run({
tournamentTeamId: id + (sz ? 100 : 0),
tournamentTeamId: teamId,
checkedInAt: dateToDatabaseTimestamp(new Date()),
});
}
@ -1008,7 +1094,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
`,
)
.run({
tournamentTeamId: id + (sz ? 100 : 0),
tournamentTeamId: id + teamIdAddition,
userId,
isOwner: i === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(new Date()),
@ -1025,14 +1111,15 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
const stageUsedCounts: Partial<Record<StageId, number>> = {};
for (const pair of shuffledPairs) {
if (sz && pair.mode !== "SZ") continue;
if (event === "ITZ" && pair.mode !== "SZ") continue;
if (pair.mode === "SZ" && SZ >= (sz ? 6 : 2)) continue;
if (pair.mode === "SZ" && SZ >= (event === "ITZ" ? 6 : 2)) continue;
if (pair.mode === "TC" && TC >= 2) continue;
if (pair.mode === "RM" && RM >= 2) continue;
if (pair.mode === "CB" && CB >= 2) continue;
if (stageUsedCounts[pair.stageId] === (sz ? 1 : 2)) continue;
if (stageUsedCounts[pair.stageId] === (event === "ITZ" ? 1 : 2))
continue;
stageUsedCounts[pair.stageId] =
(stageUsedCounts[pair.stageId] ?? 0) + 1;
@ -1052,7 +1139,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
`,
)
.run({
tournamentTeamId: id + (sz ? 100 : 0),
tournamentTeamId: id + teamIdAddition,
stageId: pair.stageId,
mode: pair.mode,
});

View File

@ -366,10 +366,25 @@ type TournamentMapPickingStyle =
| "AUTO_RM"
| "AUTO_CB";
type TournamentFormat = "SE" | "DE";
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;
teamsPerGroup?: number;
}
export interface Tournament {
format: TournamentFormat;
settings: ColumnType<TournamentSettings, string, string>;
id: GeneratedAlways<number>;
mapPickingStyle: TournamentMapPickingStyle;
showMapListGenerator: Generated<number | null>;
@ -411,6 +426,8 @@ export interface TournamentMatchGameResult {
source: string;
stageId: StageId;
winnerTeamId: number;
opponentOnePoints: number | null;
opponentTwoPoints: number | null;
}
export interface TournamentMatchGameResultParticipant {
@ -440,7 +457,7 @@ export interface TournamentStage {
number: number;
settings: string;
tournamentId: number;
type: string;
type: "double_elimination" | "single_elimination" | "round_robin";
}
export interface TournamentSub {
@ -472,6 +489,8 @@ export interface TournamentTeam {
export interface TournamentTeamCheckIn {
checkedInAt: number;
/** Which bracket checked in for. If missing is check in for the whole event. */
bracketIdx: number | null;
tournamentTeamId: number;
}

View File

@ -301,9 +301,6 @@ export enum Status {
/** The match is completed. */
Completed = 4,
/** At least one participant started their following match. */
Archived = 5,
}
/** A match between two participants (more participants are not allowed).

View File

@ -4,7 +4,7 @@ import { seed } from "~/db/seed";
import { parseRequestFormData } from "~/utils/remix";
const seedSchema = z.object({
variation: z.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT"]).nullish(),
variation: z.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT", "REG_OPEN"]).nullish(),
});
export type SeedVariation = NonNullable<

View File

@ -2,12 +2,13 @@ import type { ExpressionBuilder, Transaction } from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
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 { dateToDatabaseTimestamp } from "~/utils/dates";
import { sumArray } from "~/utils/number";
import type { Unwrapped } from "~/utils/types";
import invariant from "tiny-invariant";
// TODO: convert from raw to using the "exists" function
const hasBadge = sql<number>/* sql */ `exists (
@ -360,16 +361,26 @@ type CreateArgs = Pick<
mapPoolMaps?: Array<Pick<Tables["MapPoolMap"], "mode" | "stageId">>;
isFullTournament: boolean;
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"];
bracketProgression: TournamentSettings["bracketProgression"] | null;
teamsPerGroup?: number;
};
export async function create(args: CreateArgs) {
return db.transaction().execute(async (trx) => {
let tournamentId;
if (args.isFullTournament) {
invariant(args.bracketProgression, "Expected bracketProgression");
const settings: Tables["Tournament"]["settings"] = {
bracketProgression: args.bracketProgression,
teamsPerGroup: args.teamsPerGroup,
};
tournamentId = (
await trx
.insertInto("Tournament")
// TODO: format picking
.values({ mapPickingStyle: args.mapPickingStyle, format: "DE" })
.values({
mapPickingStyle: args.mapPickingStyle,
settings: JSON.stringify(settings),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
@ -424,6 +435,22 @@ export async function update(args: UpdateArgs) {
.returning("tournamentId")
.executeTakeFirstOrThrow();
if (tournamentId) {
invariant(args.bracketProgression, "Expected bracketProgression");
const settings: Tables["Tournament"]["settings"] = {
bracketProgression: args.bracketProgression,
teamsPerGroup: args.teamsPerGroup,
};
await trx
.updateTable("Tournament")
.set({
settings: JSON.stringify(settings),
})
.where("id", "=", tournamentId)
.execute();
}
await trx
.deleteFrom("CalendarEventDate")
.where("eventId", "=", args.eventId)

View File

@ -1,4 +1,9 @@
import type { z } from "zod";
import { isAdmin } from "~/permissions";
import type { newCalendarEventActionSchema } from "./routes/calendar.new";
import type { TournamentSettings } from "~/db/tables";
import { BRACKET_NAMES } from "../tournament/tournament-constants";
import { nullFilledArray } from "~/utils/arrays";
const usersWithTournamentPerms =
process.env["TOURNAMENT_PERMS"]?.split(",").map(Number) ?? [];
@ -7,3 +12,78 @@ export function canCreateTournament(user?: { id: number }) {
return isAdmin(user) || usersWithTournamentPerms.includes(user.id);
}
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 === "RR_TO_SE" &&
args.advancingCount &&
args.teamsPerGroup &&
args.advancingCount <= args.teamsPerGroup
) {
result.push({
name: BRACKET_NAMES.GROUPS,
type: "round_robin",
});
const allPlacements = nullFilledArray(args.teamsPerGroup).map(
(_, i) => i + 1,
);
const advancingPlacements = nullFilledArray(args.advancingCount).map(
(_, i) => i + 1,
);
result.push({
name: BRACKET_NAMES.FINALS,
type: "single_elimination",
sources: [
{
bracketIdx: 0,
placements: advancingPlacements,
},
],
});
if (
args.withUndergroundBracket &&
advancingPlacements.length !== allPlacements.length
) {
result.push({
name: BRACKET_NAMES.UNDERGROUND,
type: "single_elimination",
sources: [
{
bracketIdx: 0,
placements: allPlacements.filter(
(p) => !advancingPlacements.includes(p),
),
},
],
});
}
}
// should not happen
if (result.length === 0) return null;
return result;
}

View File

@ -1,4 +1,14 @@
import type { TournamentSettings } from "~/db/tables";
import { userDiscordIdIsAged } from "~/utils/users";
import type { TournamentFormatShort } from "../tournament/tournament-constants";
export const canAddNewEvent = (user: { discordId: string }) =>
userDiscordIdIsAged(user);
export function bracketProgressionToShortTournamentFormat(
bp: TournamentSettings["bracketProgression"],
): TournamentFormatShort {
if (bp.some((b) => b.type === "double_elimination")) return "DE";
return "RR_TO_SE";
}

View File

@ -26,11 +26,7 @@ import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { CALENDAR_EVENT } from "~/constants";
import type {
Badge as BadgeType,
CalendarEventTag,
Tournament,
} from "~/db/types";
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "react-i18next";
import { requireUser } from "~/features/auth/core/user.server";
@ -53,7 +49,7 @@ import {
type SendouRouteHandle,
} from "~/utils/remix";
import { makeTitle, pathnameFromPotentialURL } from "~/utils/strings";
import { calendarEventPage } from "~/utils/urls";
import { calendarEventPage, tournamentBracketsPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
@ -70,8 +66,24 @@ import type { RankedModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { canAddNewEvent } from "../calendar-utils";
import { canCreateTournament } from "../calendar-utils.server";
import {
bracketProgressionToShortTournamentFormat,
canAddNewEvent,
} from "../calendar-utils";
import {
canCreateTournament,
formValuesToBracketProgression,
} from "../calendar-utils.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { Tables } from "~/db/tables";
import { Divider } from "~/components/Divider";
import {
BRACKET_NAMES,
FORMATS_SHORT,
TOURNAMENT,
type TournamentFormatShort,
} from "~/features/tournament/tournament-constants";
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
@ -93,7 +105,7 @@ export const meta: MetaFunction = (args) => {
return [{ title: data.title }];
};
const newCalendarEventActionSchema = z
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
name: z
@ -139,6 +151,21 @@ const newCalendarEventActionSchema = z
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "SZ", "TC", "RM", "CB"]).optional(),
//
// tournament format related fields
//
format: z.enum(FORMATS_SHORT).nullish(),
withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()),
teamsPerGroup: z.coerce
.number()
.min(TOURNAMENT.MIN_GROUP_SIZE)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
advancingCount: z.coerce
.number()
.min(1)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
})
.refine(
async (schema) => {
@ -185,7 +212,13 @@ export const action: ActionFunction = async ({ request }) => {
toToolsEnabled: canCreateTournament(user) ? Number(data.toToolsEnabled) : 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
bracketProgression: formValuesToBracketProgression(data),
teamsPerGroup: data.teamsPerGroup ?? undefined,
};
validate(
!commonArgs.toToolsEnabled || commonArgs.bracketProgression,
"Bracket progression must be set for tournaments",
);
const deserializedMaps = (() => {
if (!data.pool) return;
@ -197,6 +230,13 @@ export const action: ActionFunction = async ({ request }) => {
const eventToEdit = badRequestIfFalsy(
await CalendarRepository.findById({ id: data.eventToEditId }),
);
if (eventToEdit.tournamentId) {
const tournament = await tournamentFromDB({
tournamentId: eventToEdit.tournamentId,
user,
});
validate(!tournament.hasStarted, "Tournament has already started", 400);
}
validate(
canEditCalendarEvent({ user, event: eventToEdit }),
"Not authorized",
@ -248,6 +288,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const canEditEvent =
eventToEdit && canEditCalendarEvent({ user, event: eventToEdit });
let tournament: Tournament | undefined;
if (eventToEdit?.tournamentId) {
tournament = await tournamentFromDB({
tournamentId: eventToEdit.tournamentId,
user,
});
if (tournament.hasStarted) {
redirect(tournamentBracketsPage({ tournamentId: tournament.ctx.id }));
}
}
return json({
managedBadges: await BadgeRepository.findManagedByUserId(user.id),
recentEventsWithMapPools:
@ -263,6 +314,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: undefined,
title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]),
canCreateTournament: canCreateTournament(user),
tournamentCtx: tournament?.ctx,
});
};
@ -297,11 +349,13 @@ export default function CalendarNewEventPage() {
<DiscordLinkInput />
<TagsAdder />
<BadgesAdder />
{/* can't edit as participants might have chosen maps and changing this might cause impossible states */}
{isTournament && !eventToEdit ? (
<TournamentMapPickingStyleSelect />
) : null}
{/* TODO: this will be selectable depending on the tournament map picking style in future */}
{!isTournament ? <MapPoolSection /> : null}
{isTournament ? <TournamentFormatSelector /> : null}
<SubmitButton className="mt-4">{t("actions.submit")}</SubmitButton>
</Form>
</Main>
@ -650,7 +704,7 @@ function BadgesAdder() {
}
const mapPickingStyleToShort: Record<
Tournament["mapPickingStyle"],
Tables["Tournament"]["mapPickingStyle"],
"ALL" | RankedModeShort
> = {
AUTO_ALL: "ALL",
@ -839,3 +893,107 @@ function MapPoolValidationStatusMessage({
</div>
);
}
function TournamentFormatSelector() {
const data = useLoaderData<typeof loader>();
const [format, setFormat] = React.useState<TournamentFormatShort>(
data.tournamentCtx?.settings.bracketProgression
? bracketProgressionToShortTournamentFormat(
data.tournamentCtx.settings.bracketProgression,
)
: "DE",
);
const [withUndergroundBracket, setWithUndergroundBracket] = React.useState(
data.tournamentCtx
? data.tournamentCtx.settings.bracketProgression.some(
(b) => b.name === BRACKET_NAMES.UNDERGROUND,
)
: true,
);
const [teamsPerGroup, setTeamsPerGroup] = React.useState(
data.tournamentCtx?.settings.teamsPerGroup ?? 4,
);
const undergroundBracketExplanation = () => {
if (format === "RR_TO_SE") {
return "Optional bracket for teams that don't make it to the final stage";
}
return "Optional bracket for teams who lose in the first two rounds of losers bracket.";
};
return (
<div className="stack md">
<Divider>Tournament format</Divider>
<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="DE">Double-elimination</option>
<option value="RR_TO_SE">
Round robin -{">"} Single-elimination
</option>
</select>
</div>
<div>
<Label htmlFor="withUndergroundBracket">With underground bracket</Label>
<Toggle
checked={withUndergroundBracket}
setChecked={setWithUndergroundBracket}
name="withUndergroundBracket"
id="withUndergroundBracket"
/>
<FormMessage type="info">{undergroundBracketExplanation()}</FormMessage>
</div>
{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 === "RR_TO_SE" ? (
<div>
<Label htmlFor="advancingCount">
Amount of teams advancing per group
</Label>
<select
defaultValue={2}
className="w-max"
name="advancingCount"
id="advancingCount"
>
{new Array(TOURNAMENT.MAX_GROUP_SIZE).fill(null).map((_, i) => {
const advancingCount = i + 1;
if (advancingCount > teamsPerGroup) return null;
return (
<option key={i} value={advancingCount}>
{advancingCount}
</option>
);
})}
</select>
</div>
) : null}
</div>
);
}

View File

@ -25,6 +25,8 @@
font-family: Lexend, sans-serif !important;
font-weight: var(--semi-bold) !important;
padding: 10px 0;
}
.brackets-viewer .opponents.connect-previous::before {
@ -39,11 +41,11 @@
border-radius: var(--rounded-sm);
}
.brackets-viewer .bracket h2 {
color: transparent;
.brackets-viewer h1 {
display: none;
}
.brackets-viewer h1 {
.brackets-viewer h2 {
display: none;
}
@ -94,3 +96,25 @@
font-size: var(--fonts-xxxs);
color: var(--theme);
}
/** Round robin */
.brackets-viewer .round-robin {
margin: 0 auto;
}
th[title="Forfeits"] {
display: none;
}
th[title="Draws"] {
display: none;
}
.group td:nth-child(5) {
display: none;
}
.group td:nth-child(7) {
display: none;
}

View File

@ -1,37 +1,28 @@
import {
Form,
useActionData,
useLoaderData,
useOutletContext,
} from "@remix-run/react";
import type { SerializeFrom } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { Image } from "~/components/Image";
import { SubmitButton } from "~/components/SubmitButton";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { NewTabs } from "~/components/NewTabs";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core";
import { Chat, useChat } from "~/features/chat/components/Chat";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
import { type TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import {
HACKY_resolvePoolCode,
mapCountPlayedInSetWithCertainty,
resolveHostingTeam,
resolveRoomPass,
} from "../tournament-bracket-utils";
import type { SerializeFrom } from "@remix-run/node";
import type { Unpacked } from "~/utils/types";
import type {
TournamentLoaderTeam,
TournamentLoaderData,
} from "~/features/tournament";
import { canReportTournamentScore, isTournamentOrganizer } from "~/permissions";
import { useUser } from "~/features/auth/core";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampToDate } from "~/utils/dates";
import { NewTabs } from "~/components/NewTabs";
import { ScoreReporterRosters } from "./ScoreReporterRosters";
import { Chat, useChat } from "~/features/chat/components/Chat";
import * as React from "react";
import type { TournamentDataTeam } from "../core/Tournament.server";
export type Result = Unpacked<
SerializeFrom<TournamentMatchLoaderData>["results"]
@ -47,7 +38,7 @@ export function ScoreReporter({
result,
type,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
teams: [TournamentDataTeam, TournamentDataTeam];
result?: Result;
currentStageWithMode: TournamentMapListMap;
modes: ModeShort[];
@ -58,9 +49,8 @@ export function ScoreReporter({
}) {
const { t } = useTranslation(["tournament"]);
const isMounted = useIsMounted();
const actionData = useActionData<{ error?: "locked" }>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
const scoreOne = data.match.opponentOne?.score ?? 0;
@ -92,15 +82,13 @@ export function ScoreReporter({
<span>
{t("tournament:match.pool")}{" "}
{
HACKY_resolvePoolCode({
event: parentRouteData.tournament,
tournament.resolvePoolCode({
hostingTeamId: resolveHostingTeam(teams).id,
}).prefix
}
<span className="text-theme font-bold">
{
HACKY_resolvePoolCode({
event: parentRouteData.tournament,
tournament.resolvePoolCode({
hostingTeamId: resolveHostingTeam(teams).id,
}).lastDigit
}
@ -116,8 +104,6 @@ export function ScoreReporter({
</>,
];
const matchIsLockedError = actionData?.error === "locked";
return (
<div className="tournament-bracket__during-match-actions">
<FancyStageBanner
@ -139,13 +125,9 @@ export function ScoreReporter({
</div>
</Form>
)}
{isTournamentOrganizer({
user,
tournament: parentRouteData.tournament,
}) &&
!parentRouteData.hasFinalized &&
presentational &&
!matchIsLockedError && (
{tournament.isOrganizer(user) &&
tournament.matchCanBeReopened(data.match.id) &&
presentational && (
<Form method="post">
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
@ -158,18 +140,6 @@ export function ScoreReporter({
</div>
</Form>
)}
{matchIsLockedError && (
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
_action="REOPEN_MATCH"
className="tournament-bracket__stage-banner__undo-button"
disabled
testId="match-is-locked-button"
>
{t("tournament:match.action.matchIsLocked")}
</SubmitButton>
</div>
)}
</FancyStageBanner>
<ModeProgressIndicator
modes={modes}
@ -212,7 +182,7 @@ function FancyStageBanner({
stage: TournamentMapListMap;
infos?: (JSX.Element | null)[];
children?: React.ReactNode;
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const { t } = useTranslation(["game-misc", "tournament"]);
@ -345,11 +315,11 @@ function MatchActionSectionTabs({
presentational?: boolean;
scores: [number, number];
currentStageWithMode: TournamentMapListMap;
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
teams: [TournamentDataTeam, TournamentDataTeam];
result?: Result;
}) {
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
const [_unseenMessages, setUnseenMessages] = React.useState(0);
const [chatVisible, setChatVisible] = React.useState(false);
@ -358,20 +328,25 @@ function MatchActionSectionTabs({
return Object.fromEntries(
[
...data.match.players.map((p) => ({ ...p, title: undefined })),
...parentRouteData.tournament.staff.map((s) => ({
...tournament.ctx.staff.map((s) => ({
...s,
title: s.role === "STREAMER" ? "Stream" : "TO",
})),
{
...parentRouteData.tournament.author,
...tournament.ctx.author,
title: "TO",
},
].map((p) => [p.id, p]),
);
}, [data, parentRouteData]);
}, [data, tournament]);
const showChat =
data.match.chatCode &&
(data.match.players.some((p) => p.id === user?.id) ||
tournament.isOrganizerOrStreamer(user));
const rooms = React.useMemo(() => {
return data.match.chatCode
return showChat && data.match.chatCode
? [
{
code: data.match.chatCode,
@ -379,7 +354,7 @@ function MatchActionSectionTabs({
},
]
: [];
}, [data.match.chatCode]);
}, [showChat, data.match.chatCode]);
const onNewMessage = React.useCallback(() => {
setUnseenMessages((msg) => msg + 1);
@ -400,10 +375,6 @@ function MatchActionSectionTabs({
const currentPosition = scores[0] + scores[1];
const isMemberOfATeamInTheMatch = data.match.players.some(
(p) => p.id === user?.id,
);
return (
<ActionSectionWrapper>
<NewTabs
@ -411,7 +382,7 @@ function MatchActionSectionTabs({
{
label: "Chat",
number: unseenMessages,
hidden: !data.match.chatCode,
hidden: !showChat,
},
{
label: presentational ? "Score" : "Report score",
@ -421,10 +392,10 @@ function MatchActionSectionTabs({
content={[
{
key: "chat",
hidden: !data.match.chatCode,
hidden: !showChat,
element: (
<>
{data.match.chatCode ? (
{showChat ? (
<Chat
rooms={rooms}
users={chatUsers}
@ -441,6 +412,7 @@ function MatchActionSectionTabs({
},
{
key: "report",
unmount: false,
element: (
<ScoreReporterRosters
// Without the key prop when switching to another match the winnerId is remembered
@ -454,12 +426,7 @@ function MatchActionSectionTabs({
result={result}
bestOf={data.match.bestOf}
presentational={
!canReportTournamentScore({
tournament: parentRouteData.tournament,
match: data.match,
isMemberOfATeamInTheMatch,
user,
})
!tournament.canReportScore({ matchId: data.match.id, user })
}
/>
),

View File

@ -1,6 +1,5 @@
import * as React from "react";
import { Form, useLoaderData } from "@remix-run/react";
import type { TournamentLoaderTeam } from "../../tournament/routes/to.$id";
import { TOURNAMENT } from "../../tournament/tournament-constants";
import { SubmitButton } from "~/components/SubmitButton";
import { TeamRosterInputs } from "./TeamRosterInputs";
@ -11,6 +10,8 @@ import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import type { SerializeFrom } from "@remix-run/node";
import { stageImageUrl } from "~/utils/urls";
import { Image } from "~/components/Image";
import type { TournamentDataTeam } from "../core/Tournament.server";
import { useTournament } from "~/features/tournament/routes/to.$id";
export function ScoreReporterRosters({
teams,
@ -21,7 +22,7 @@ export function ScoreReporterRosters({
bestOf,
presentational: _presentational,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
teams: [TournamentDataTeam, TournamentDataTeam];
position: number;
currentStageWithMode: TournamentMapListMap;
result?: Result;
@ -29,6 +30,7 @@ export function ScoreReporterRosters({
bestOf: number;
presentational?: boolean;
}) {
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
const [checkedPlayers, setCheckedPlayers] = React.useState<
[number[], number[]]
@ -40,6 +42,7 @@ export function ScoreReporterRosters({
}),
);
const [winnerId, setWinnerId] = React.useState<number | undefined>();
const [points, setPoints] = React.useState<[number, number]>([0, 0]);
const presentational = _presentational || Boolean(result);
@ -49,6 +52,10 @@ export function ScoreReporterRosters({
];
const wouldEndSet = newScore.some((score) => score > bestOf / 2);
const showPoints = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectResultsWithPoints;
return (
<Form method="post" className="width-full">
<div>
@ -58,6 +65,8 @@ export function ScoreReporterRosters({
setWinnerId={setWinnerId}
checkedPlayers={checkedPlayers}
setCheckedPlayers={setCheckedPlayers}
points={showPoints ? points : undefined}
setPoints={setPoints}
result={result}
/>
{!presentational ? (
@ -68,8 +77,17 @@ export function ScoreReporterRosters({
name="playerIds"
value={JSON.stringify(checkedPlayers.flat())}
/>
{showPoints ? (
<input
type="hidden"
name="points"
value={JSON.stringify(points)}
/>
) : null}
<input type="hidden" name="position" value={position} />
<ReportScoreButtons
winnerIdx={winnerId ? winningTeamIdx() : undefined}
points={showPoints ? points : undefined}
checkedPlayers={checkedPlayers}
winnerName={winningTeam()}
currentStageWithMode={currentStageWithMode}
@ -95,6 +113,14 @@ export function ScoreReporterRosters({
throw new Error("No winning team matching the id");
}
function winningTeamIdx() {
if (!winnerId) return;
if (teams[0].id === winnerId) return 0;
if (teams[1].id === winnerId) return 1;
throw new Error("No winning team matching the id");
}
}
// TODO: remember what previously selected for our team
@ -128,11 +154,15 @@ function checkedPlayersInitialState({
}
function ReportScoreButtons({
points,
winnerIdx,
checkedPlayers,
winnerName,
currentStageWithMode,
wouldEndSet,
}: {
points?: [number, number];
winnerIdx?: number;
checkedPlayers: number[][];
winnerName?: string;
currentStageWithMode: TournamentMapListMap;
@ -148,6 +178,30 @@ function ReportScoreButtons({
);
}
if (
points &&
typeof winnerIdx === "number" &&
points[winnerIdx] <= points[winnerIdx === 0 ? 1 : 0]
) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
Winner should have more points than loser
</p>
);
}
if (
points &&
((points[0] === 100 && points[1] !== 0) ||
(points[0] !== 0 && points[1] === 100))
) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
If there was a KO (100 points), other team should have 0 points
</p>
);
}
if (
!checkedPlayers.every(
(team) => team.length === TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,

View File

@ -3,15 +3,13 @@ import clone from "just-clone";
import * as React from "react";
import { TOURNAMENT } from "../../tournament/tournament-constants";
import { Label } from "~/components/Label";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "../../tournament/routes/to.$id";
import { useTournament } from "../../tournament/routes/to.$id";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import { Link, useLoaderData } from "@remix-run/react";
import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import type { Result } from "./ScoreReporter";
import { tournamentTeamPage } from "~/utils/urls";
import type { TournamentDataTeam } from "../core/Tournament.server";
/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */
export function TeamRosterInputs({
@ -20,83 +18,128 @@ export function TeamRosterInputs({
setWinnerId,
checkedPlayers,
setCheckedPlayers,
points: _points,
setPoints,
result,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
teams: [TournamentDataTeam, TournamentDataTeam];
winnerId?: number | null;
setWinnerId: (newId?: number) => void;
checkedPlayers: [number[], number[]];
setCheckedPlayers?: (newPlayerIds: [number[], number[]]) => void;
points?: [number, number];
setPoints: (newPoints: [number, number]) => void;
result?: Result;
}) {
const presentational = Boolean(result);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
React.useEffect(() => {
setWinnerId(undefined);
}, [data, setWinnerId]);
setPoints([0, 0]);
}, [data, setWinnerId, setPoints]);
const points =
typeof result?.opponentOnePoints === "number" &&
typeof result?.opponentTwoPoints === "number"
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: _points;
return (
<div className="tournament-bracket__during-match-actions__rosters">
{teams.map((team, teamI) => (
<div key={team.id}>
<div className="text-xs text-lighter font-semi-bold stack horizontal xs items-center justify-center">
<div
className={
teamI === 0
? "tournament-bracket__team-one-dot"
: "tournament-bracket__team-two-dot"
}
/>
Team {teamI + 1}
</div>
<h4>
<span className="tournament-bracket__during-match-actions__seed">
#{data.seeds[teamI]}
</span>{" "}
<Link
to={tournamentTeamPage({
eventId: parentRouteData.tournament.id,
tournamentTeamId: team.id,
})}
className="tournament-bracket__during-match-actions__team-name"
>
{team.name}
</Link>
</h4>
<WinnerRadio
presentational={presentational}
checked={
result ? result.winnerTeamId === team.id : winnerId === team.id
}
teamId={team.id}
onChange={() => setWinnerId?.(team.id)}
team={teamI + 1}
/>
<TeamRosterInputsCheckboxes
teamId={team.id}
checkedPlayers={result?.participantIds ?? checkedPlayers[teamI]!}
presentational={presentational}
handlePlayerClick={(playerId: number) => {
const newCheckedPlayers = () => {
const newPlayers = clone(checkedPlayers);
if (checkedPlayers.flat().includes(playerId)) {
newPlayers[teamI] = newPlayers[teamI]!.filter(
(id) => id !== playerId,
);
} else {
newPlayers[teamI]!.push(playerId);
}
{teams.map((team, teamI) => {
const winnerRadioChecked = result
? result.winnerTeamId === team.id
: winnerId === team.id;
return newPlayers;
};
setCheckedPlayers?.(newCheckedPlayers());
}}
/>
</div>
))}
// just so we can center the points nicely
const showWinnerRadio =
!points || !presentational || winnerRadioChecked;
const seed = tournament.seedByTeamId(team.id);
return (
<div key={team.id}>
<div className="text-xs text-lighter font-semi-bold stack horizontal xs items-center justify-center">
<div
className={
teamI === 0
? "tournament-bracket__team-one-dot"
: "tournament-bracket__team-two-dot"
}
/>
Team {teamI + 1}
</div>
<h4>
{seed ? (
<span className="tournament-bracket__during-match-actions__seed">
#{seed}
</span>
) : null}{" "}
<Link
to={tournamentTeamPage({
eventId: tournament.ctx.id,
tournamentTeamId: team.id,
})}
className="tournament-bracket__during-match-actions__team-name"
>
{team.name}
</Link>
</h4>
<div
className={clsx("stack horizontal md justify-center", {
"mt-1": points && !presentational,
})}
>
{showWinnerRadio ? (
<WinnerRadio
presentational={presentational}
checked={winnerRadioChecked}
teamId={team.id}
onChange={() => setWinnerId?.(team.id)}
team={teamI + 1}
/>
) : null}
{points ? (
<PointInput
value={points[teamI]}
onChange={(newPoint: number) => {
const newPoints = clone(points);
newPoints[teamI] = newPoint;
setPoints(newPoints);
}}
presentational={presentational}
/>
) : null}
</div>
<TeamRosterInputsCheckboxes
teamId={team.id}
checkedPlayers={result?.participantIds ?? checkedPlayers[teamI]!}
presentational={presentational}
handlePlayerClick={(playerId: number) => {
const newCheckedPlayers = () => {
const newPlayers = clone(checkedPlayers);
if (checkedPlayers.flat().includes(playerId)) {
newPlayers[teamI] = newPlayers[teamI]!.filter(
(id) => id !== playerId,
);
} else {
newPlayers[teamI]!.push(playerId);
}
return newPlayers;
};
setCheckedPlayers?.(newCheckedPlayers());
}}
/>
</div>
);
})}
</div>
);
}
@ -120,7 +163,7 @@ function WinnerRadio({
if (presentational) {
return (
<div
className={clsx("text-xs font-bold", {
className={clsx("text-xs font-bold stack justify-center", {
invisible: !checked,
"text-theme": team === 1,
"text-theme-secondary": team === 2,
@ -147,6 +190,44 @@ function WinnerRadio({
);
}
function PointInput({
value,
onChange,
presentational,
}: {
value: number;
onChange: (newPoint: number) => void;
presentational: boolean;
}) {
const id = React.useId();
if (presentational) {
return (
<div className="text-xs text-lighter">
{value === 100 ? <>KO</> : <>{value}p</>}
</div>
);
}
return (
<div className="stack horizontal sm items-center">
<input
className="tournament-bracket__points-input"
onChange={(e) => onChange(Number(e.target.value))}
type="number"
min={0}
max={100}
value={value}
required
id={id}
/>
<Label htmlFor={id} spaced={false}>
Points
</Label>
</div>
);
}
function TeamRosterInputsCheckboxes({
teamId,
checkedPlayers,

View File

@ -0,0 +1,707 @@
import invariant from "tiny-invariant";
import type { Tables, TournamentBracketProgression } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import { assertUnreachable } from "~/utils/types";
import type { OptionalIdObject, Tournament } from "./Tournament";
import type { TournamentDataTeam } from "./Tournament.server";
import { removeDuplicates } from "~/utils/arrays";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import { logger } from "~/utils/logger";
interface CreateBracketArgs {
id: number;
preview: boolean;
data: ValueToArray<DataTypes>;
type: Tables["TournamentStage"]["type"];
canBeStarted?: boolean;
name: string;
teamsPendingCheckIn?: number[];
tournament: Tournament;
sources?: {
bracketIdx: number;
placements: number[];
}[];
}
export interface Standing {
team: TournamentDataTeam;
placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th...
}
export abstract class Bracket {
id;
preview;
data;
canBeStarted;
name;
teamsPendingCheckIn;
tournament;
sources;
constructor({
id,
preview,
data,
canBeStarted,
name,
teamsPendingCheckIn,
tournament,
sources,
}: Omit<CreateBracketArgs, "format">) {
this.id = id;
this.preview = preview;
this.data = data;
this.canBeStarted = canBeStarted;
this.name = name;
this.teamsPendingCheckIn = teamsPendingCheckIn;
this.tournament = tournament;
this.sources = sources;
}
get collectResultsWithPoints() {
return false;
}
get type(): TournamentBracketProgression[number]["type"] {
throw new Error("not implemented");
}
get standings(): Standing[] {
throw new Error("not implemented");
}
protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] {
return standings.map((standing) => {
return {
...standing,
team: {
...standing.team,
members: standing.team.members.filter((member) =>
this.tournament.ctx.participatedUsers.includes(member.userId),
),
},
};
});
}
get isUnderground() {
return this.name === BRACKET_NAMES.UNDERGROUND;
}
get everyMatchOver() {
if (this.preview) return false;
for (const match of this.data.match) {
// BYE
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
return false;
}
}
return true;
}
get enoughTeams() {
return this.data.participant.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START;
}
canCheckIn(user: OptionalIdObject) {
// using regular check-in
if (!this.teamsPendingCheckIn) return false;
const team = this.tournament.ownedTeamByUser(user);
if (!team) return false;
return this.teamsPendingCheckIn.includes(team.id);
}
source(_placements: number[]): {
relevantMatchesFinished: boolean;
teams: { id: number; name: string }[];
} {
throw new Error("not implemented");
}
teamsWithNames(teams: { id: number }[]) {
return teams.map((team) => {
const name = this.data.participant.find(
(participant) => participant.id === team.id,
)?.name;
invariant(name, `Team name not found for id: ${team.id}`);
return {
id: team.id,
name,
};
});
}
static create(
args: CreateBracketArgs,
): SingleEliminationBracket | DoubleEliminationBracket | RoundRobinBracket {
switch (args.type) {
case "single_elimination": {
return new SingleEliminationBracket(args);
}
case "double_elimination": {
return new DoubleEliminationBracket(args);
}
case "round_robin": {
return new RoundRobinBracket(args);
}
default: {
assertUnreachable(args.type);
}
}
}
}
class SingleEliminationBracket extends Bracket {
constructor(args: CreateBracketArgs) {
super(args);
}
get type(): TournamentBracketProgression[number]["type"] {
return "single_elimination";
}
get standings(): Standing[] {
const teams: { id: number; lostAt: number }[] = [];
for (const match of this.data.match
.slice()
.sort((a, b) => a.round_id - b.round_id)) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
continue;
}
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push({ id: loser.id, lostAt: match.round_id });
}
const teamCountWhoDidntLoseYet =
this.data.participant.length - teams.length;
const result: Standing[] = [];
for (const roundId of removeDuplicates(teams.map((team) => team.lostAt))) {
const teamsLostThisRound: { id: number }[] = [];
while (teams.length && teams[0].lostAt === roundId) {
teamsLostThisRound.push(teams.shift()!);
}
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove = teamCountWhoDidntLoseYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
});
}
}
if (teamCountWhoDidntLoseYet === 1) {
const winner = this.data.participant.find((participant) =>
result.every(({ team }) => team.id !== participant.id),
);
invariant(winner, "No winner identified");
const winnerTeam = this.tournament.teamById(winner.id);
invariant(winnerTeam, `Winner team not found for id: ${winner.id}`);
result.push({
team: winnerTeam,
placement: 1,
});
}
// TODO: 3rd place match
return this.standingsWithoutNonParticipants(result.reverse());
}
}
class DoubleEliminationBracket extends Bracket {
constructor(args: CreateBracketArgs) {
super(args);
}
get type(): TournamentBracketProgression[number]["type"] {
return "double_elimination";
}
get standings(): Standing[] {
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
const teams: { id: number; lostAt: number }[] = [];
for (const match of this.data.match
.slice()
.sort((a, b) => a.round_id - b.round_id)) {
if (match.group_id !== losersGroupId) continue;
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
continue;
}
// BYE
if (!match.opponent1 || !match.opponent2) continue;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push({ id: loser.id, lostAt: match.round_id });
}
const teamCountWhoDidntLoseInLosersYet =
this.data.participant.length - teams.length;
const result: Standing[] = [];
for (const roundId of removeDuplicates(teams.map((team) => team.lostAt))) {
const teamsLostThisRound: { id: number }[] = [];
while (teams.length && teams[0].lostAt === roundId) {
teamsLostThisRound.push(teams.shift()!);
}
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove =
teamCountWhoDidntLoseInLosersYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
});
}
}
// edge case: 1 match only
const noLosersRounds = !losersGroupId;
const grandFinalsNumber = noLosersRounds ? 1 : 3;
const grandFinalsGroupId = this.data.group.find(
(g) => g.number === grandFinalsNumber,
)?.id;
invariant(grandFinalsGroupId, "GF group not found");
const grandFinalMatches = this.data.match.filter(
(match) => match.group_id === grandFinalsGroupId,
);
// if opponent1 won in DE it means that bracket reset is not played
if (
grandFinalMatches[0].opponent1 &&
(noLosersRounds || grandFinalMatches[0].opponent1.result === "win")
) {
const loserTeam = this.tournament.teamById(
grandFinalMatches[0].opponent2!.id!,
);
invariant(loserTeam, "Loser team not found");
const winnerTeam = this.tournament.teamById(
grandFinalMatches[0].opponent1.id!,
);
invariant(winnerTeam, "Winner team not found");
result.push({
team: loserTeam,
placement: 2,
});
result.push({
team: winnerTeam,
placement: 1,
});
} else if (
grandFinalMatches[1].opponent1?.result === "win" ||
grandFinalMatches[1].opponent2?.result === "win"
) {
const loser =
grandFinalMatches[1].opponent1?.result === "win"
? "opponent2"
: "opponent1";
const winner = loser === "opponent1" ? "opponent2" : "opponent1";
const loserTeam = this.tournament.teamById(
grandFinalMatches[1][loser]!.id!,
);
invariant(loserTeam, "Loser team not found");
const winnerTeam = this.tournament.teamById(
grandFinalMatches[1][winner]!.id!,
);
invariant(winnerTeam, "Winner team not found");
result.push({
team: loserTeam,
placement: 2,
});
result.push({
team: winnerTeam,
placement: 1,
});
}
return this.standingsWithoutNonParticipants(result.reverse());
}
get everyMatchOver() {
if (this.preview) return false;
let lastWinner = -1;
for (const [i, match] of this.data.match.entries()) {
// special case - bracket reset might not be played depending on who wins in the grands
const isLast = i === this.data.match.length - 1;
if (isLast && lastWinner === 1) {
continue;
}
// BYE
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
return false;
}
lastWinner = match.opponent1?.result === "win" ? 1 : 2;
}
return true;
}
source(placements: number[]) {
const resolveLosersGroupId = (data: ValueToArray<DataTypes>) => {
const minGroupId = Math.min(...data.round.map((round) => round.group_id));
return minGroupId + 1;
};
const placementsToRoundsIds = (
data: ValueToArray<DataTypes>,
losersGroupId: number,
) => {
const firstRoundIsOnlyByes = () => {
const losersMatches = data.match.filter(
(match) => match.group_id === losersGroupId,
);
const fistRoundId = Math.min(...losersMatches.map((m) => m.round_id));
const firstRoundMatches = losersMatches.filter(
(match) => match.round_id === fistRoundId,
);
return firstRoundMatches.every(
(match) => match.opponent1 === null || match.opponent2 === null,
);
};
const losersRounds = data.round.filter(
(round) => round.group_id === losersGroupId,
);
const orderedRoundsIds = losersRounds
.map((round) => round.id)
.sort((a, b) => a - b);
const amountOfRounds =
Math.abs(Math.min(...placements)) + (firstRoundIsOnlyByes() ? 1 : 0);
return orderedRoundsIds.slice(0, amountOfRounds);
};
invariant(
placements.every((placement) => placement < 0),
"Positive placements in DE not implemented",
);
const losersGroupId = resolveLosersGroupId(this.data);
const sourceRoundsIds = placementsToRoundsIds(
this.data,
losersGroupId,
).sort(
// teams who made it further in the bracket get higher seed
(a, b) => b - a,
);
const teams: { id: number }[] = [];
let relevantMatchesFinished = true;
for (const roundId of sourceRoundsIds) {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === roundId,
);
for (const match of roundsMatches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
relevantMatchesFinished = false;
continue;
}
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push({ id: loser.id });
}
}
return {
relevantMatchesFinished,
teams: this.teamsWithNames(teams),
};
}
}
class RoundRobinBracket extends Bracket {
constructor(args: CreateBracketArgs) {
super(args);
}
get collectResultsWithPoints() {
return true;
}
source(placements: number[]): {
relevantMatchesFinished: boolean;
teams: { id: number; name: string }[];
} {
if (placements.some((p) => p < 0)) {
throw new Error("Negative placements not implemented");
}
const standings = this.standings;
const relevantMatchesFinished =
standings.length === this.data.participant.length;
const uniquePlacements = removeDuplicates(
standings.map((s) => s.placement),
);
// 1,3,5 -> 1,2,3 e.g.
const placementNormalized = (p: number) => {
return uniquePlacements.indexOf(p) + 1;
};
return {
relevantMatchesFinished,
teams: standings
.filter((s) => placements.includes(placementNormalized(s.placement)))
.map((s) => ({ id: s.team.id, name: s.team.name })),
};
}
get standings(): Standing[] {
const groupIds = this.data.group.map((group) => group.id);
const placements: (Standing & { groupId: number })[] = [];
for (const groupId of groupIds) {
const matches = this.data.match.filter(
(match) => match.group_id === groupId,
);
const groupIsFinished = matches.every(
(match) =>
// BYE
match.opponent1 === null ||
match.opponent2 === null ||
// match was played out
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
);
if (!groupIsFinished) continue;
const teams: {
id: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
winsAgainstTied: number;
points: number;
}[] = [];
const updateTeam = ({
teamId,
setWins,
setLosses,
mapWins,
mapLosses,
points,
}: {
teamId: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
points: number;
}) => {
const team = teams.find((team) => team.id === teamId);
if (team) {
team.setWins += setWins;
team.setLosses += setLosses;
team.mapWins += mapWins;
team.mapLosses += mapLosses;
team.points += points;
} else {
teams.push({
id: teamId,
setWins,
setLosses,
mapWins,
mapLosses,
winsAgainstTied: 0,
points,
});
}
};
for (const match of matches) {
const winner =
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
if (!winner || !loser) continue;
invariant(
typeof winner.id === "number" &&
typeof loser.id === "number" &&
typeof winner.score === "number" &&
typeof loser.score === "number",
);
if (
typeof winner.totalPoints !== "number" ||
typeof loser.totalPoints !== "number"
) {
logger.warn(
"RoundRobinBracket.standings: winner or loser points not found",
);
}
updateTeam({
teamId: winner.id,
setWins: 1,
setLosses: 0,
mapWins: winner.score,
mapLosses: loser.score,
points: winner.totalPoints ?? 0,
});
updateTeam({
teamId: loser.id,
setWins: 0,
setLosses: 1,
mapWins: loser.score,
mapLosses: winner.score,
points: loser.totalPoints ?? 0,
});
}
for (const team of teams) {
for (const team2 of teams) {
if (team.id === team2.id) continue;
if (team.setWins !== team2.setWins) continue;
// they are different teams and are tied, let's check who won
const wonTheirMatch = matches.some(
(match) =>
(match.opponent1?.id === team.id &&
match.opponent2?.id === team2.id &&
match.opponent1?.result === "win") ||
(match.opponent1?.id === team2.id &&
match.opponent2?.id === team.id &&
match.opponent2?.result === "win"),
);
if (wonTheirMatch) {
team.winsAgainstTied++;
}
}
}
placements.push(
...teams
.sort((a, b) => {
if (a.setWins > b.setWins) return -1;
if (a.setWins < b.setWins) return 1;
if (a.winsAgainstTied > b.winsAgainstTied) return -1;
if (a.winsAgainstTied < b.winsAgainstTied) return 1;
if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;
if (a.points > b.points) return -1;
if (a.points < b.points) return 1;
const aSeed = Number(this.tournament.seedByTeamId(a.id));
const bSeed = Number(this.tournament.seedByTeamId(b.id));
if (aSeed < bSeed) return -1;
if (aSeed > bSeed) return 1;
return 0;
})
.map((team, i) => {
return {
team: this.tournament.teamById(team.id)!,
placement: i + 1,
groupId,
};
}),
);
}
const sorted = placements.sort((a, b) => {
if (a.placement < b.placement) return -1;
if (a.placement > b.placement) return 1;
if (a.groupId < b.groupId) return -1;
if (a.groupId > b.groupId) return 1;
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
};
});
}
get type(): TournamentBracketProgression[number]["type"] {
return "round_robin";
}
}

View File

@ -0,0 +1,53 @@
import { Tournament } from "./Tournament";
import { getTournamentManager } from "..";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { notFoundIfFalsy } from "~/utils/remix";
import type { Unwrapped } from "~/utils/types";
const manager = getTournamentManager("SQL");
export type TournamentData = Unwrapped<typeof tournamentData>;
export type TournamentDataTeam = TournamentData["ctx"]["teams"][number];
export async function tournamentData({
user,
tournamentId,
}: {
user?: { id: number };
tournamentId: number;
}) {
const ctx = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const revealAllMapPools =
ctx.inProgressBrackets.length > 0 ||
ctx.author.id === user?.id ||
ctx.staff.some(
(staff) => staff.id === user?.id && staff.role === "ORGANIZER",
);
return {
data: manager.get.tournamentData(tournamentId),
ctx: {
...ctx,
teams: ctx.teams.map((team) => {
const isOwnTeam = team.members.some(
(member) => member.userId === user?.id,
);
return {
...team,
mapPool: revealAllMapPools || isOwnTeam ? team.mapPool : null,
inviteCode: isOwnTeam ? team.inviteCode : null,
};
}),
},
};
}
export async function tournamentFromDB(args: {
user: { id: number } | undefined;
tournamentId: number;
}) {
return new Tournament(await tournamentData(args));
}

View File

@ -0,0 +1,611 @@
import invariant from "tiny-invariant";
import type { TournamentBracketProgression } from "~/db/tables";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import { isAdmin } from "~/permissions";
import { TOURNAMENT } from "~/features/tournament";
import type { TournamentData } from "./Tournament.server";
import {
HACKY_isInviteOnlyEvent,
HACKY_resolvePicture,
} from "~/features/tournament/tournament-utils";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists";
import { databaseTimestampToDate } from "~/utils/dates";
import { getTournamentManager } from "..";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
import type { Stage } from "~/modules/brackets-model";
import { Bracket } from "./Bracket";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
export type OptionalIdObject = { id: number } | undefined;
/** Extends and providers utility functions on top of the bracket-manager library. Updating data after the bracket has started is responsibility of bracket-manager. */
export class Tournament {
brackets: Bracket[] = [];
ctx;
constructor({ data, ctx }: TournamentData) {
const hasStarted = ctx.inProgressBrackets.length > 0;
const teamsInSeedOrder = ctx.teams.sort((a, b) => {
if (a.seed && b.seed) {
return a.seed - b.seed;
}
if (a.seed && !b.seed) {
return -1;
}
if (!a.seed && b.seed) {
return 1;
}
return a.createdAt - b.createdAt;
});
this.ctx = {
...ctx,
teams: hasStarted
? // after the start the teams who did not check-in are irrelevant
teamsInSeedOrder.filter((team) => team.checkIns.length > 0)
: teamsInSeedOrder,
startTime: databaseTimestampToDate(ctx.startTime),
};
this.initBrackets(data);
}
private initBrackets(data: ValueToArray<DataTypes>) {
for (const [
bracketIdx,
{ type, name, sources },
] of this.ctx.settings.bracketProgression.entries()) {
const inProgressStage = this.ctx.inProgressBrackets.find(
(stage) => stage.name === name,
);
if (inProgressStage) {
const match = data.match.filter(
(match) => match.stage_id === inProgressStage.id,
);
const participants = new Set(
match
.flatMap((match) => [match.opponent1?.id, match.opponent2?.id])
.filter((id) => typeof id === "number"),
);
this.brackets.push(
Bracket.create({
id: inProgressStage.id,
tournament: this,
preview: false,
name,
sources,
data: {
...data,
participant: data.participant.filter((participant) =>
participants.has(participant.id),
),
group: data.group.filter(
(group) => group.stage_id === inProgressStage.id,
),
match,
stage: data.stage.filter(
(stage) => stage.id === inProgressStage.id,
),
round: data.round.filter(
(round) => round.stage_id === inProgressStage.id,
),
},
type,
}),
);
} else {
const manager = getTournamentManager("IN_MEMORY");
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources)
: {
teams: this.ctx.teams,
relevantMatchesFinished: true,
};
const { checkedInTeams, notCheckedInTeams } =
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
});
if (checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
const seeding = checkedInTeams.map((team) => team.name);
manager.create({
tournamentId: this.ctx.id,
name,
type,
seeding:
type === "round_robin"
? seeding
: fillWithNullTillPowerOfTwo(seeding),
settings: this.bracketSettings(type, checkedInTeams.length),
});
}
this.brackets.push(
Bracket.create({
id: -1 * bracketIdx,
tournament: this,
preview: true,
name,
data: manager.get.tournamentData(this.ctx.id),
type,
sources,
canBeStarted:
checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START &&
(sources ? relevantMatchesFinished : this.regularCheckInHasEnded),
teamsPendingCheckIn:
bracketIdx !== 0 ? notCheckedInTeams.map((t) => t.id) : undefined,
}),
);
}
}
}
private resolveTeamsFromSources(
sources: NonNullable<TournamentBracketProgression[number]["sources"]>,
) {
const teams: { id: number; name: string }[] = [];
let allRelevantMatchesFinished = true;
for (const { bracketIdx, placements } of sources) {
const sourceBracket = this.bracketByIdx(bracketIdx);
invariant(sourceBracket, "Bracket not found");
const { teams: sourcedTeams, relevantMatchesFinished } =
sourceBracket.source(placements);
if (!relevantMatchesFinished) {
allRelevantMatchesFinished = false;
}
teams.push(...sourcedTeams);
}
return { teams, relevantMatchesFinished: allRelevantMatchesFinished };
}
private divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
}: {
teams: { id: number; name: string }[];
bracketIdx: number;
}) {
return teams.reduce(
(acc, cur) => {
const team = this.teamById(cur.id);
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)
) {
acc.checkedInTeams.push(cur);
} else {
acc.notCheckedInTeams.push(cur);
}
}
return acc;
},
{ checkedInTeams: [], notCheckedInTeams: [] } as {
checkedInTeams: { id: number; name: string }[];
notCheckedInTeams: { id: number; name: string }[];
},
);
}
bracketSettings(
type: TournamentBracketProgression[number]["type"],
participantsCount: number,
): Stage["settings"] {
switch (type) {
case "single_elimination":
return { consolationFinal: false };
case "double_elimination":
return {
grandFinal: "double",
};
case "round_robin":
return {
groupCount: Math.ceil(
participantsCount / (this.ctx.settings.teamsPerGroup ?? 4),
),
seedOrdering: ["groups.seed_optimized"],
};
default: {
assertUnreachable(type);
}
}
}
get logoSrc() {
return HACKY_resolvePicture(this.ctx);
}
get modesIncluded(): ModeShort[] {
switch (this.ctx.mapPickingStyle) {
case "AUTO_SZ": {
return ["SZ"];
}
case "AUTO_TC": {
return ["TC"];
}
case "AUTO_RM": {
return ["RM"];
}
case "AUTO_CB": {
return ["CB"];
}
default: {
return [...rankedModesShort];
}
}
}
resolvePoolCode({ hostingTeamId }: { hostingTeamId: number }) {
const tournamentNameWithoutOnlyLetters = this.ctx.name.replace(
/[^a-zA-Z]/g,
"",
);
const prefix = tournamentNameWithoutOnlyLetters
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 3);
const lastDigit = hostingTeamId % 10;
return { prefix, lastDigit };
}
get mapPickCountPerMode() {
return this.modesIncluded.length === 1
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
}
get hasOpenRegistration() {
return !HACKY_isInviteOnlyEvent(this.ctx);
}
get hasStarted() {
return this.brackets.some((bracket) => !bracket.preview);
}
get everyBracketOver() {
if (this.ctx.isFinalized) return true;
return this.brackets.every((bracket) => bracket.everyMatchOver);
}
teamById(id: number) {
return this.ctx.teams.find((team) => team.id === id);
}
seedByTeamId(id: number) {
const idx = this.ctx.teams.findIndex((team) => team.id === id);
if (idx === -1) return null;
return idx + 1;
}
participatedPlayersByTeamId(id: number) {
const team = this.teamById(id);
invariant(team, "Team not found");
return team.members.filter((member) =>
this.ctx.participatedUsers.includes(member.userId),
);
}
matchIdToBracketIdx(matchId: number) {
const idx = this.brackets.findIndex((bracket) =>
bracket.data.match.some((match) => match.id === matchId),
);
if (idx === -1) return null;
return idx;
}
get standings() {
for (const bracket of this.brackets) {
if (bracket.name === BRACKET_NAMES.MAIN) {
return bracket.standings;
}
if (bracket.name === BRACKET_NAMES.FINALS) {
const finalsStandings = bracket.standings;
const firstStageStandings = this.brackets[0].standings;
const uniqueFinalsPlacements = new Set<number>();
const firstStageWithoutFinalsParticipants = firstStageStandings.filter(
(firstStageStanding) => {
const isFinalsParticipant = finalsStandings.some(
(finalsStanding) =>
finalsStanding.team.id === firstStageStanding.team.id,
);
if (isFinalsParticipant) {
uniqueFinalsPlacements.add(firstStageStanding.placement);
}
return !isFinalsParticipant;
},
);
return [
...finalsStandings,
...firstStageWithoutFinalsParticipants.filter(
// handle edge case where teams didn't check in to the final stage despite being qualified
// although this would bug out if all teams of certain placement fail to check in
// but probably that should not happen too likely
(p) => !uniqueFinalsPlacements.has(p.placement),
),
];
}
}
logger.warn("Standings not found");
return [];
}
canFinalize(user: OptionalIdObject) {
// can skip underground bracket
const relevantBrackets = this.brackets.filter(
(b) => !b.preview || !b.isUnderground,
);
return (
relevantBrackets.every((b) => b.everyMatchOver) &&
this.isOrganizer(user) &&
!this.ctx.isFinalized
);
}
canReportScore({
matchId,
user,
}: {
matchId: number;
user: OptionalIdObject;
}) {
const match = this.brackets
.flatMap((bracket) => (bracket.preview ? [] : bracket.data.match))
.find((match) => match.id === matchId);
invariant(match, "Match not found");
// match didn't start yet
if (!match.opponent1 || !match.opponent2) return false;
const matchIsOver =
match.opponent1.result === "win" || match.opponent2.result === "win";
if (matchIsOver) return false;
const teamMemberOf = this.teamMemberOfByUser(user)?.id;
const isParticipant =
match.opponent1.id === teamMemberOf ||
match.opponent2.id === teamMemberOf;
return isParticipant || this.isOrganizer(user);
}
checkInConditionsFulfilled({
tournamentTeamId,
mapPool,
}: {
tournamentTeamId: number;
mapPool: unknown[];
}) {
const team = this.teamById(tournamentTeamId);
invariant(team, "Team not found");
if (!this.regularCheckInIsOpen && !this.regularCheckInHasEnded) {
return false;
}
if (team.members.length < TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
return false;
}
if (mapPool.length === 0) {
return false;
}
return true;
}
// TODO: get from settings
private isInvitational() {
return this.ctx.name.includes("Finale");
}
get subsFeatureEnabled() {
return !this.isInvitational();
}
get maxTeamMemberCount() {
const maxMembersBeforeStart = this.isInvitational()
? 5
: TOURNAMENT.DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START;
if (this.hasStarted) {
return maxMembersBeforeStart + 1;
}
return maxMembersBeforeStart;
}
get regularCheckInIsOpen() {
return (
this.regularCheckInStartsAt < new Date() &&
this.regularCheckInEndsAt > new Date()
);
}
get regularCheckInHasEnded() {
return this.ctx.startTime < new Date();
}
get regularCheckInStartInThePast() {
return this.regularCheckInStartsAt < new Date();
}
get regularCheckInStartsAt() {
const result = new Date(this.ctx.startTime);
result.setMinutes(result.getMinutes() - 60);
return result;
}
get regularCheckInEndsAt() {
return this.ctx.startTime;
}
bracketByIdxOrDefault(idx: number): Bracket {
const bracket = this.brackets[idx];
if (bracket) return bracket;
const defaultBracket = this.brackets[0];
invariant(defaultBracket, "No brackets found");
logger.warn("Bracket not found, using fallback bracket");
return defaultBracket;
}
bracketByIdx(idx: number) {
const bracket = this.brackets[idx];
if (!bracket) return null;
return bracket;
}
ownedTeamByUser(
user: OptionalIdObject,
): ((typeof this.ctx.teams)[number] & { inviteCode: string }) | null {
if (!user) return null;
return this.ctx.teams.find((team) =>
team.members.some(
(member) => member.userId === user.id && member.isOwner,
),
) as (typeof this.ctx.teams)[number] & { inviteCode: string };
}
teamMemberOfByUser(user: OptionalIdObject) {
if (!user) return null;
return this.ctx.teams.find((team) =>
team.members.some((member) => member.userId === user.id),
);
}
// basic idea is that they can reopen match as long as they don't have a following match
// in progress whose participants could be dependent on the results of this match
matchCanBeReopened(matchId: number) {
if (this.ctx.isFinalized) return false;
const allMatches = this.brackets.flatMap((bracket) =>
// preview matches don't even have real id's and anyway don't prevent anything
bracket.preview ? [] : bracket.data.match,
);
const match = allMatches.find((match) => match.id === matchId);
if (!match) {
logger.error("matchCanBeReopened: Match not found");
return false;
}
const bracketIdx = this.matchIdToBracketIdx(matchId);
if (typeof bracketIdx !== "number") {
logger.error("matchCanBeReopened: Bracket not found");
return false;
}
const bracket = this.bracketByIdx(bracketIdx);
invariant(bracket, "Bracket not found");
const hasInProgressFollowUpBracket = this.brackets.some(
(b) => !b.preview && b.sources?.some((s) => s.bracketIdx === bracketIdx),
);
if (hasInProgressFollowUpBracket) return false;
// round robin matches don't prevent reopening
if (bracket.type === "round_robin") return true;
// BYE match
if (!match.opponent1 || !match.opponent2) return false;
const anotherMatchBlocking = allMatches
.filter(
// only interested in matches of the same bracket & not the match being reopened itself
(match2) =>
match2.stage_id === match.stage_id && match2.id !== match.id,
)
.some((match2) => {
const hasAtLeastOneMapReported =
(match2.opponent1?.score && match2.opponent1.score > 0) ||
(match2.opponent2?.score && match2.opponent2.score > 0);
const hasSameParticipant =
match2.opponent1?.id === match.opponent1?.id ||
match2.opponent1?.id === match.opponent2?.id ||
match2.opponent2?.id === match.opponent1?.id ||
match2.opponent2?.id === match.opponent2?.id;
const isFollowingMatch =
match2.group_id > match.group_id || match2.round_id > match.round_id;
return (
hasAtLeastOneMapReported && isFollowingMatch && hasSameParticipant
);
});
return !anotherMatchBlocking;
}
isOrganizer(user: OptionalIdObject) {
if (!user) return false;
if (isAdmin(user)) return true;
return this.ctx.staff.some(
(staff) => staff.id === user.id && staff.role === "ORGANIZER",
);
}
isOrganizerOrStreamer(user: OptionalIdObject) {
if (!user) return false;
if (isAdmin(user)) return true;
return this.ctx.staff.some(
(staff) =>
staff.id === user.id && ["ORGANIZER", "STREAMER"].includes(staff.role),
);
}
isAdmin(user: OptionalIdObject) {
if (!user) return false;
if (isAdmin(user)) return true;
return this.ctx.author.id === user.id;
}
}

View File

@ -1,10 +1,20 @@
import invariant from "tiny-invariant";
import type { FindAllMatchesByTournamentIdMatch } from "../queries/findAllMatchesByTournamentId.server";
import type { FindAllMatchesByStageIdItem } from "../queries/findAllMatchesByStageId.server";
import type { TournamentStage } from "~/db/tables";
// TODO: this only works for double elimination
export function resolveBestOfs(
matches: Array<FindAllMatchesByTournamentIdMatch>,
) {
matches: Array<FindAllMatchesByStageIdItem>,
type: TournamentStage["type"],
): [bestOf: 3 | 5 | 7, id: number][] {
if (type === "round_robin") {
return matches.map((match) => [3, match.matchId]);
}
if (type === "single_elimination") {
return matches.map((match) => [5, match.matchId]);
}
// Double Elimination logic
// 3 is default
const result: [bestOf: 5 | 7, id: number][] = [];

View File

@ -4,13 +4,13 @@ import { resolveBestOfs } from "./bestOf.server";
const ResolveBestOfs = suite("resolveBestOfs()");
const count = (bestOfs: [bestOf: 5 | 7, id: number][], target: 5 | 7) =>
const count = (bestOfs: [bestOf: 3 | 5 | 7, id: number][], target: 5 | 7) =>
bestOfs.reduce((acc, cur) => acc + (cur[0] === target ? 1 : 0), 0);
ResolveBestOfs("2 teams", () => {
const matches = [{ matchId: 1, roundNumber: 1, groupNumber: 1 }];
const bestOfs = resolveBestOfs(matches);
const bestOfs = resolveBestOfs(matches, "double_elimination");
assert.equal(count(bestOfs, 5), 0);
assert.equal(count(bestOfs, 7), 1);
@ -27,7 +27,7 @@ ResolveBestOfs("4 teams", () => {
{ matchId: 7, roundNumber: 2, groupNumber: 3 },
];
const bestOfs = resolveBestOfs(matches);
const bestOfs = resolveBestOfs(matches, "double_elimination");
assert.equal(count(bestOfs, 5), 3);
});
@ -51,7 +51,7 @@ ResolveBestOfs("8 teams", () => {
{ matchId: 15, roundNumber: 2, groupNumber: 3 },
];
const bestOfs = resolveBestOfs(matches);
const bestOfs = resolveBestOfs(matches, "double_elimination");
assert.equal(count(bestOfs, 5), 4);
});

View File

@ -342,9 +342,14 @@ const match_getByIdStm = sql.prepare(/*sql*/ `
`);
const match_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."stageId" = @stageId
select
"TournamentMatch".*,
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal"
from "TournamentMatch"
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
where "TournamentMatch"."stageId" = @stageId
group by "TournamentMatch"."id"
`);
const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
@ -414,14 +419,31 @@ export class Match {
this.status = status;
}
static #convertMatch(rawMatch: TournamentMatch): MatchType {
static #convertMatch(
rawMatch: TournamentMatch & {
opponentOnePointsTotal: number | null;
opponentTwoPointsTotal: number | null;
},
): MatchType {
return {
id: rawMatch.id,
child_count: rawMatch.childCount,
group_id: rawMatch.groupId,
number: rawMatch.number,
opponent1: JSON.parse(rawMatch.opponentOne),
opponent2: JSON.parse(rawMatch.opponentTwo),
opponent1:
rawMatch.opponentOne === "null"
? null
: {
...JSON.parse(rawMatch.opponentOne),
totalPoints: rawMatch.opponentOnePointsTotal ?? undefined,
},
opponent2:
rawMatch.opponentTwo === "null"
? null
: {
...JSON.parse(rawMatch.opponentTwo),
totalPoints: rawMatch.opponentTwoPointsTotal ?? undefined,
},
round_id: rawMatch.roundId,
stage_id: rawMatch.stageId,
status: rawMatch.status,

View File

@ -8,7 +8,6 @@ import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.se
import type { FindTeamsByTournamentIdItem } from "../../tournament/queries/findTeamsByTournamentId.server";
import invariant from "tiny-invariant";
import { removeDuplicates } from "~/utils/arrays";
import type { FinalStanding } from "./finalStandings.server";
import type { Rating } from "openskill/dist/types";
import {
rate,
@ -17,6 +16,7 @@ import {
} from "~/features/mmr/mmr-utils";
import shuffle from "just-shuffle";
import type { Unpacked } from "~/utils/types";
import type { Standing } from "./Bracket";
export interface TournamentSummary {
skills: Omit<
@ -36,13 +36,6 @@ type TeamsArg = Array<{
Pick<Unpacked<FindTeamsByTournamentIdItem["members"]>, "userId">
>;
}>;
type FinalStandingsArg = Array<{
placement: FinalStanding["placement"];
tournamentTeam: {
id: FinalStanding["tournamentTeam"]["id"];
};
players: Array<{ id: number }>;
}>;
export function tournamentSummary({
results,
@ -55,7 +48,7 @@ export function tournamentSummary({
}: {
results: AllMatchResult[];
teams: TeamsArg;
finalStandings: FinalStandingsArg;
finalStandings: Standing[];
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
@ -473,17 +466,17 @@ function tournamentResults({
finalStandings,
}: {
participantCount: number;
finalStandings: FinalStandingsArg;
finalStandings: Standing[];
}) {
const result: TournamentSummary["tournamentResults"] = [];
for (const standing of finalStandings) {
for (const player of standing.players) {
for (const player of standing.team.members) {
result.push({
participantCount,
placement: standing.placement,
tournamentTeamId: standing.tournamentTeam.id,
userId: player.id,
tournamentTeamId: standing.team.id,
userId: player.userId,
});
}
}

View File

@ -3,39 +3,50 @@ import * as assert from "uvu/assert";
import { tournamentSummary } from "./summarizer.server";
import { ordinal, rating } from "openskill";
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
import type { TournamentDataTeam } from "./Tournament.server";
const TournamentSummary = suite("tournamentSummary()");
const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({
checkIns: [],
createdAt: 0,
id: teamId,
inviteCode: null,
mapPool: [],
members: userIds.map((userId) => ({
country: null,
customUrl: null,
discordAvatar: null,
discordId: "123",
discordName: "test",
inGameName: "test",
isOwner: 0,
plusTier: null,
userId,
})),
name: "Team " + teamId,
prefersNotToHost: 0,
seed: 1,
});
function summarize({ results }: { results?: AllMatchResult[] } = {}) {
return tournamentSummary({
finalStandings: [
{
placement: 1,
players: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
tournamentTeam: {
id: 1,
},
team: createTeam(1, [1, 2, 3, 4]),
},
{
placement: 2,
players: [{ id: 5 }, { id: 6 }, { id: 7 }, { id: 8 }],
tournamentTeam: {
id: 2,
},
team: createTeam(2, [5, 6, 7, 8]),
},
{
placement: 3,
players: [{ id: 9 }, { id: 10 }, { id: 11 }, { id: 12 }],
tournamentTeam: {
id: 3,
},
team: createTeam(3, [9, 10, 11, 12]),
},
{
placement: 4,
players: [{ id: 13 }, { id: 14 }, { id: 15 }, { id: 16 }],
tournamentTeam: {
id: 4,
},
team: createTeam(4, [13, 14, 15, 16]),
},
],
results: results ?? [

View File

@ -0,0 +1,638 @@
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
stage: [
{
id: 0,
tournament_id: 1,
name: "Groups stage",
type: "round_robin",
number: 1,
settings: {
groupCount: 1,
roundRobinMode: "simple",
matchesChildCount: 0,
size: 4,
seedOrdering: ["groups.seed_optimized"],
},
},
],
group: [
{
id: 0,
stage_id: 0,
number: 1,
},
],
round: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
},
{
id: 1,
number: 2,
stage_id: 0,
group_id: 0,
},
{
id: 2,
number: 3,
stage_id: 0,
group_id: 0,
},
],
match: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 0,
child_count: 0,
status: 2,
opponent1: {
id: 0,
position: 1,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 1,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 0,
child_count: 0,
status: 2,
opponent1: {
id: 2,
position: 3,
},
opponent2: {
id: 1,
position: 2,
},
},
{
id: 2,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 1,
child_count: 0,
status: 2,
opponent1: {
id: 1,
position: 2,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 3,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 1,
child_count: 0,
status: 2,
opponent1: {
id: 0,
position: 1,
},
opponent2: {
id: 2,
position: 3,
},
},
{
id: 4,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 2,
child_count: 0,
status: 2,
opponent1: {
id: 2,
position: 3,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 5,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 2,
child_count: 0,
status: 2,
opponent1: {
id: 1,
position: 2,
},
opponent2: {
id: 0,
position: 1,
},
},
],
match_game: [],
participant: [
{
id: 0,
tournament_id: 1,
name: "Team 1",
},
{
id: 1,
tournament_id: 1,
name: "Team 2",
},
{
id: 2,
tournament_id: 1,
name: "Team 3",
},
{
id: 3,
tournament_id: 1,
name: "Team 4",
},
],
});
export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
stage: [
{
id: 0,
tournament_id: 3,
name: "Groups stage",
type: "round_robin",
number: 1,
settings: {
groupCount: 1,
seedOrdering: ["groups.seed_optimized"],
roundRobinMode: "simple",
matchesChildCount: 0,
size: 5,
},
},
],
group: [
{
id: 0,
stage_id: 0,
number: 1,
},
],
round: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
},
{
id: 1,
number: 2,
stage_id: 0,
group_id: 0,
},
{
id: 2,
number: 3,
stage_id: 0,
group_id: 0,
},
{
id: 3,
number: 4,
stage_id: 0,
group_id: 0,
},
{
id: 4,
number: 5,
stage_id: 0,
group_id: 0,
},
],
match: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 0,
child_count: 0,
status: 2,
opponent1: {
id: 4,
position: 5,
},
opponent2: {
id: 1,
position: 2,
},
},
{
id: 2,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 1,
child_count: 0,
status: 2,
opponent1: {
id: 0,
position: 1,
},
opponent2: {
id: 2,
position: 3,
},
},
{
id: 3,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 1,
child_count: 0,
status: 2,
opponent1: {
id: 4,
position: 5,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 4,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 2,
child_count: 0,
status: 2,
opponent1: {
id: 1,
position: 2,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 5,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 2,
child_count: 0,
status: 2,
opponent1: {
id: 0,
position: 1,
},
opponent2: {
id: 4,
position: 5,
},
},
{
id: 6,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 3,
child_count: 0,
status: 2,
opponent1: {
id: 2,
position: 3,
},
opponent2: {
id: 4,
position: 5,
},
},
{
id: 7,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 3,
child_count: 0,
status: 2,
opponent1: {
id: 1,
position: 2,
},
opponent2: {
id: 0,
position: 1,
},
},
{
id: 8,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 4,
child_count: 0,
status: 2,
opponent1: {
id: 3,
position: 4,
},
opponent2: {
id: 0,
position: 1,
},
},
{
id: 9,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 4,
child_count: 0,
status: 2,
opponent1: {
id: 2,
position: 3,
},
opponent2: {
id: 1,
position: 2,
},
},
{
id: 1,
number: 2,
stage_id: 0,
group_id: 0,
round_id: 0,
child_count: 0,
status: 2,
opponent1: {
id: 3,
position: 4,
},
opponent2: {
id: 2,
position: 3,
},
},
],
match_game: [],
participant: [
{
id: 0,
tournament_id: 3,
name: "Team 1",
},
{
id: 1,
tournament_id: 3,
name: "Team 2",
},
{
id: 2,
tournament_id: 3,
name: "Team 3",
},
{
id: 3,
tournament_id: 3,
name: "Team 4",
},
{
id: 4,
tournament_id: 3,
name: "Team 5",
},
],
});
export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
stage: [
{
id: 0,
tournament_id: 3,
name: "Groups stage",
type: "round_robin",
number: 1,
settings: {
groupCount: 2,
seedOrdering: ["groups.seed_optimized"],
roundRobinMode: "simple",
matchesChildCount: 0,
size: 6,
},
},
],
group: [
{
id: 0,
stage_id: 0,
number: 1,
},
{
id: 1,
stage_id: 0,
number: 2,
},
],
round: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
},
{
id: 1,
number: 2,
stage_id: 0,
group_id: 0,
},
{
id: 2,
number: 3,
stage_id: 0,
group_id: 0,
},
{
id: 3,
number: 1,
stage_id: 0,
group_id: 1,
},
{
id: 4,
number: 2,
stage_id: 0,
group_id: 1,
},
{
id: 5,
number: 3,
stage_id: 0,
group_id: 1,
},
],
match: [
{
id: 0,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 0,
child_count: 0,
status: 2,
opponent1: {
id: 4,
position: 5,
},
opponent2: {
id: 3,
position: 4,
},
},
{
id: 1,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 1,
child_count: 0,
status: 2,
opponent1: {
id: 0,
position: 1,
},
opponent2: {
id: 4,
position: 5,
},
},
{
id: 2,
number: 1,
stage_id: 0,
group_id: 0,
round_id: 2,
child_count: 0,
status: 2,
opponent1: {
id: 3,
position: 4,
},
opponent2: {
id: 0,
position: 1,
},
},
{
id: 3,
number: 1,
stage_id: 0,
group_id: 1,
round_id: 3,
child_count: 0,
status: 2,
opponent1: {
id: 5,
position: 6,
},
opponent2: {
id: 2,
position: 3,
},
},
{
id: 4,
number: 1,
stage_id: 0,
group_id: 1,
round_id: 4,
child_count: 0,
status: 2,
opponent1: {
id: 1,
position: 2,
},
opponent2: {
id: 5,
position: 6,
},
},
{
id: 5,
number: 1,
stage_id: 0,
group_id: 1,
round_id: 5,
child_count: 0,
status: 2,
opponent1: {
id: 2,
position: 3,
},
opponent2: {
id: 1,
position: 2,
},
},
],
match_game: [],
participant: [
{
id: 0,
tournament_id: 3,
name: "Team 1",
},
{
id: 1,
tournament_id: 3,
name: "Team 2",
},
{
id: 2,
tournament_id: 3,
name: "Team 3",
},
{
id: 3,
tournament_id: 3,
name: "Team 4",
},
{
id: 4,
tournament_id: 3,
name: "Team 5",
},
{
id: 5,
tournament_id: 3,
name: "Team 6",
},
],
});

View File

@ -0,0 +1,289 @@
import { suite } from "uvu";
import { FIVE_TEAMS_RR, FOUR_TEAMS_RR, SIX_TEAMS_TWO_GROUPS_RR } from "./mocks";
import { adjustResults, testTournament } from "./test-utils";
import * as assert from "uvu/assert";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import type { TournamentData } from "../Tournament.server";
const RoundRobinStandings = suite("Round Robin Standings");
const roundRobinTournamentCtx: Partial<TournamentData["ctx"]> = {
settings: {
bracketProgression: [{ name: BRACKET_NAMES.GROUPS, type: "round_robin" }],
},
inProgressBrackets: [
{ id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS },
],
};
RoundRobinStandings("resolves standings from points", () => {
const tournament = testTournament(
adjustResults(FOUR_TEAMS_RR(), [
{ ids: [0, 3], score: [2, 0] },
{ ids: [2, 1], score: [0, 2] },
{ ids: [1, 3], score: [2, 0] },
{ ids: [0, 2], score: [2, 0] },
{ ids: [2, 3], score: [2, 0] },
{ ids: [1, 0], score: [0, 2] },
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings.length, 4);
assert.equal(standings[0].team.id, 0);
assert.equal(standings[0].placement, 1);
assert.equal(standings[1].team.id, 1);
assert.equal(standings[2].team.id, 2);
assert.equal(standings[3].team.id, 3);
});
RoundRobinStandings("tiebreaker via head-to-head", () => {
// id 0 = WWWL
// id 1 = WWWL
// id 2 = WWLL
// id 3 = WWLL but won against 2
// id 4 = LLLL
const tournament = testTournament(
adjustResults(FIVE_TEAMS_RR(), [
{
ids: [4, 1],
score: [0, 2],
},
{
ids: [0, 2],
score: [2, 0],
},
{
ids: [4, 3],
score: [0, 2],
},
{
ids: [1, 3],
score: [2, 0],
},
{
ids: [0, 4],
score: [2, 0],
},
{
ids: [2, 4],
score: [2, 0],
},
{
ids: [1, 0],
score: [2, 0],
},
{
ids: [3, 0],
score: [0, 2],
},
{
ids: [2, 1],
score: [2, 0],
},
{
ids: [3, 2],
score: [2, 0],
},
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings.length, 5);
assert.equal(standings[2].team.id, 3);
assert.equal(standings[2].placement, 3);
assert.equal(standings[3].team.id, 2);
assert.equal(standings[3].placement, 4);
});
RoundRobinStandings("tiebreaker via maps won", () => {
// id 0 = WWWW
// id 1 = WWLL
// id 2 = WWLL
// id 3 = WWLL
const tournament = testTournament(
adjustResults(FOUR_TEAMS_RR(), [
{ ids: [0, 3], score: [2, 0] },
{ ids: [2, 1], score: [0, 2] },
{ ids: [1, 3], score: [0, 2] },
{ ids: [0, 2], score: [2, 0] },
{ ids: [2, 3], score: [2, 1] },
{ ids: [1, 0], score: [0, 2] },
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
// they won the most maps out of the 3 tied teams
assert.equal(standings[1].team.id, 3);
});
RoundRobinStandings("three way tiebreaker via points scored", () => {
// id 0 = LLL
// id 1 = WWL
// id 2 = WWL
// id 3 = WWL
const tournament = testTournament(
adjustResults(FOUR_TEAMS_RR(), [
{ ids: [0, 3], score: [0, 2], points: [0, 200] },
{ ids: [2, 1], score: [0, 2], points: [50, 100] },
{ ids: [1, 3], score: [0, 2], points: [0, 200] },
{ ids: [0, 2], score: [0, 2], points: [0, 200] },
{ ids: [2, 3], score: [2, 0], points: [150, 149] },
{ ids: [1, 0], score: [2, 0], points: [200, 0] },
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings[0].team.id, 3);
assert.equal(standings[1].team.id, 2);
});
RoundRobinStandings("if everything is tied, uses seeds as tiebreaker", () => {
// id 0 = LLL
// id 1 = WWL
// id 2 = WWL
// id 3 = WWL
const tournament = testTournament(
adjustResults(FOUR_TEAMS_RR(), [
{ ids: [0, 3], score: [0, 2], points: [0, 200] },
{ ids: [2, 1], score: [0, 2], points: [0, 200] },
{ ids: [1, 3], score: [0, 2], points: [0, 200] },
{ ids: [0, 2], score: [0, 2], points: [0, 200] },
{ ids: [2, 3], score: [2, 0], points: [200, 0] },
{ ids: [1, 0], score: [2, 0], points: [200, 0] },
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings[0].team.id, 1);
assert.equal(standings[1].team.id, 2);
});
RoundRobinStandings("if two groups finished, standings for both groups", () => {
const tournament = testTournament(
adjustResults(SIX_TEAMS_TWO_GROUPS_RR(), [
{
ids: [4, 3],
score: [2, 0],
},
{
ids: [0, 4],
score: [2, 0],
},
{
ids: [3, 0],
score: [2, 0],
},
{
ids: [5, 2],
score: [2, 0],
},
{
ids: [1, 5],
score: [2, 0],
},
{
ids: [2, 1],
score: [2, 0],
},
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings.length, 6);
assert.equal(standings.filter((s) => s.placement === 1).length, 2);
});
RoundRobinStandings(
"if one group finished and other ongoing, standings for just one group",
() => {
const tournament = testTournament(
adjustResults(SIX_TEAMS_TWO_GROUPS_RR(), [
{
ids: [4, 3],
score: [2, 0],
},
{
ids: [0, 4],
score: [2, 0],
},
{
ids: [3, 0],
score: [2, 0],
},
{
ids: [5, 2],
score: [2, 0],
},
{
ids: [1, 5],
score: [2, 0],
},
{
ids: [2, 1],
score: [0, 0],
},
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings.length, 3);
assert.equal(standings.filter((s) => s.placement === 1).length, 1);
},
);
RoundRobinStandings(
"teams with same placements are ordered by group id",
() => {
const base = SIX_TEAMS_TWO_GROUPS_RR();
const tournament = testTournament(
adjustResults({ ...base, group: base.group.reverse() }, [
{
ids: [4, 3],
score: [2, 0],
},
{
ids: [0, 4],
score: [0, 2],
},
{
ids: [3, 0],
score: [2, 0],
},
{
ids: [5, 2],
score: [2, 0],
},
{
ids: [1, 5],
score: [2, 0],
},
{
ids: [2, 1],
score: [2, 0],
},
]),
roundRobinTournamentCtx,
);
const standings = tournament.bracketByIdx(0)!.standings;
assert.equal(standings[0].team.id, 4);
},
);
RoundRobinStandings.run();

View File

@ -0,0 +1,126 @@
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import { Tournament } from "../Tournament";
import type { TournamentData } from "../Tournament.server";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
const tournamentCtxTeam = (
teamId: number,
partial?: Partial<TournamentData["ctx"]["teams"][0]>,
): TournamentData["ctx"]["teams"][0] => {
return {
checkIns: [{ checkedInAt: 1705858841, bracketIdx: null }],
createdAt: 0,
id: teamId,
inviteCode: null,
mapPool: [],
members: [],
name: "Team " + teamId,
prefersNotToHost: 0,
seed: teamId + 1,
...partial,
};
};
const nTeams = (n: number, startingId: number) => {
const teams = [];
for (let i = 0; i < n; i++) {
teams.push(tournamentCtxTeam(i, tournamentCtxTeam(i + startingId)));
}
return teams;
};
export const testTournament = (
data: ValueToArray<DataTypes>,
partialCtx?: Partial<TournamentData["ctx"]>,
) => {
return new Tournament({
data,
ctx: {
eventId: 1,
id: 1,
description: null,
startTime: 1705858842,
isFinalized: 0,
name: "test",
showMapListGenerator: 0,
castTwitchAccounts: [],
staff: [],
tieBreakerMapPool: [],
participatedUsers: [],
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [
{ name: BRACKET_NAMES.MAIN, type: "double_elimination" },
],
},
inProgressBrackets: data.stage.map((stage) => ({
id: stage.id,
name: stage.name,
type: stage.type,
})),
bestOfs: data.round.map((round) => ({ bestOf: 3, roundId: round.id })),
teams: nTeams(
data.participant.length,
Math.min(...data.participant.map((p) => p.id)),
),
author: {
chatNameColor: null,
customUrl: null,
discordAvatar: null,
discordId: "123",
discordName: "test",
id: 1,
},
...partialCtx,
},
});
};
export const adjustResults = (
data: ValueToArray<DataTypes>,
adjustedArr: Array<{
ids: [number, number];
score: [number, number];
points?: [number, number];
}>,
): ValueToArray<DataTypes> => {
return {
...data,
match: data.match.map((match, idx) => {
const adjusted = adjustedArr[idx];
if (!adjusted) throw new Error("No adjusted result for match " + idx);
if (adjusted.ids[0] !== match.opponent1!.id) {
throw new Error("Adjusted match opponent1 id does not match");
}
if (adjusted.ids[1] !== match.opponent2!.id) {
throw new Error("Adjusted match opponent2 id does not match");
}
return {
...match,
opponent1: {
...match.opponent1!,
score: adjusted.score[0],
result: adjusted.score[0] > adjusted.score[1] ? "win" : "loss",
totalPoints: adjusted.points
? adjusted.points[0]
: adjusted.score[0] > adjusted.score[1]
? 100
: 0,
},
opponent2: {
...match.opponent2!,
score: adjusted.score[1],
result: adjusted.score[1] > adjusted.score[0] ? "win" : "loss",
totalPoints: adjusted.points
? adjusted.points[1]
: adjusted.score[1] > adjusted.score[0]
? 100
: 0,
},
};
}),
};
};

View File

@ -0,0 +1 @@
// TODO: tests about DE->SE underground bracket progression

View File

@ -1,7 +1,4 @@
export {
HACKY_resolvePoolCode,
everyMatchIsOver,
} from "./tournament-bracket-utils";
export { everyMatchIsOver } from "./tournament-bracket-utils";
export { getTournamentManager } from "./core/brackets-manager";
export { finalStandingOfTeam } from "./core/finalStandings.server";
export { findMapPoolByTeamId } from "./queries/findMapPoolByTeamId.server";

View File

@ -9,15 +9,15 @@ const stm = sql.prepare(/* sql */ `
left join "TournamentRound" on "TournamentRound"."id" = "TournamentMatch"."roundId"
left join "TournamentGroup" on "TournamentGroup"."id" = "TournamentMatch"."groupId"
left join "TournamentStage" on "TournamentStage"."id" = "TournamentMatch"."stageId"
where "TournamentStage"."tournamentId" = @tournamentId
where "TournamentStage"."id" = @stageId
`);
export interface FindAllMatchesByTournamentIdMatch {
export interface FindAllMatchesByStageIdItem {
matchId: number;
roundNumber: number;
groupNumber: number;
}
export function findAllMatchesByTournamentId(tournamentId: number) {
return stm.all({ tournamentId }) as Array<FindAllMatchesByTournamentIdMatch>;
export function findAllMatchesByStageId(stageId: number) {
return stm.all({ stageId }) as Array<FindAllMatchesByStageIdItem>;
}

View File

@ -1,4 +1,5 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { TournamentMatchGameResult, User } from "~/db/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import { parseDBArray } from "~/utils/sql";
@ -11,6 +12,8 @@ const stm = sql.prepare(/* sql */ `
"TournamentMatchGameResult"."mode",
"TournamentMatchGameResult"."source",
"TournamentMatchGameResult"."createdAt",
"TournamentMatchGameResult"."opponentOnePoints",
"TournamentMatchGameResult"."opponentTwoPoints",
json_group_array("TournamentMatchGameResultParticipant"."userId") as "participantIds"
from "TournamentMatchGameResult"
left join "TournamentMatchGameResultParticipant"
@ -28,6 +31,8 @@ interface FindResultsByMatchIdResult {
participantIds: Array<User["id"]>;
source: TournamentMaplistSource;
createdAt: TournamentMatchGameResult["createdAt"];
opponentOnePoints: Tables["TournamentMatchGameResult"]["opponentOnePoints"];
opponentTwoPoints: Tables["TournamentMatchGameResult"]["opponentTwoPoints"];
}
export function findResultsByMatchId(

View File

@ -1,16 +1,17 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { TournamentMatchGameResult } from "~/db/types";
const stm = sql.prepare(/* sql */ `
insert into "TournamentMatchGameResult"
("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source")
("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source", "opponentOnePoints", "opponentTwoPoints")
values
(@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source)
(@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source, @opponentOnePoints, @opponentTwoPoints)
returning *
`);
export function insertTournamentMatchGameResult(
args: Omit<TournamentMatchGameResult, "id" | "createdAt">,
args: Omit<Tables["TournamentMatchGameResult"], "id" | "createdAt">,
) {
return stm.get(args) as TournamentMatchGameResult;
}

View File

@ -1,27 +1,39 @@
import type {
ActionFunction,
LinksFunction,
LoaderFunctionArgs,
SerializeFrom,
} from "@remix-run/node";
import type { ActionFunction, LinksFunction } from "@remix-run/node";
import {
Form,
Link,
useLoaderData,
useFetcher,
useNavigate,
useOutletContext,
useRevalidator,
} from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import bracketViewerStyles from "../brackets-viewer.css";
import bracketStyles from "../tournament-bracket.css";
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { useEventSource } from "remix-utils/sse/react";
import invariant from "tiny-invariant";
import { Alert } from "~/components/Alert";
import { Button } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Popover } from "~/components/Popover";
import { SubmitButton } from "~/components/SubmitButton";
import { getTournamentManager } from "../core/brackets-manager";
import hasTournamentStarted from "../../tournament/queries/hasTournamentStarted.server";
import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { sql } from "~/db/sql";
import { Status } from "~/db/types";
import { requireUser, useUser } from "~/features/auth/core";
import {
currentSeason,
queryCurrentTeamRating,
queryCurrentUserRating,
} from "~/features/mmr";
import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server";
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { HACKY_isInviteOnlyEvent } from "~/features/tournament/tournament-utils";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import {
SENDOU_INK_BASE_URL,
tournamentBracketsSubscribePage,
@ -30,61 +42,28 @@ import {
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import type { TournamentLoaderData } from "../../tournament/routes/to.$id";
import { useTournament } from "../../tournament/routes/to.$id";
import bracketViewerStyles from "../brackets-viewer.css";
import { tournamentFromDB } from "../core/Tournament.server";
import { resolveBestOfs } from "../core/bestOf.server";
import { findAllMatchesByTournamentId } from "../queries/findAllMatchesByTournamentId.server";
import { getTournamentManager } from "../core/brackets-manager";
import { tournamentSummary } from "../core/summarizer.server";
import { addSummary } from "../queries/addSummary.server";
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
import { findAllMatchesByStageId } from "../queries/findAllMatchesByStageId.server";
import { setBestOf } from "../queries/setBestOf.server";
import { isTournamentOrganizer } from "~/permissions";
import { requireUser, useUser } from "~/features/auth/core";
import {
TOURNAMENT,
tournamentIdFromParams,
checkInHasStarted,
teamHasCheckedIn,
} from "~/features/tournament";
import { bracketSchema } from "../tournament-bracket-schemas.server";
import {
bracketSubscriptionKey,
everyMatchIsOver,
fillWithNullTillPowerOfTwo,
resolveTournamentStageName,
resolveTournamentStageSettings,
resolveTournamentStageType,
} from "../tournament-bracket-utils";
import { sql } from "~/db/sql";
import { useEventSource } from "remix-utils/sse/react";
import { Status } from "~/db/types";
import clsx from "clsx";
import { Button, LinkButton } from "~/components/Button";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { bestOfsByTournamentId } from "../queries/bestOfsByTournamentId.server";
import type { FinalStanding } from "../core/finalStandings.server";
import { finalStandings } from "../core/finalStandings.server";
import bracketStyles from "../tournament-bracket.css";
import type { Standing } from "../core/Bracket";
import { removeDuplicates } from "~/utils/arrays";
import { Placement } from "~/components/Placement";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { removeDuplicates } from "~/utils/arrays";
import { Flag } from "~/components/Flag";
import { databaseTimestampToDate } from "~/utils/dates";
import { Popover } from "~/components/Popover";
import { useCopyToClipboard } from "react-use";
import { useTranslation } from "react-i18next";
import { bracketSchema } from "../tournament-bracket-schemas.server";
import { addSummary } from "../queries/addSummary.server";
import { tournamentSummary } from "../core/summarizer.server";
import invariant from "tiny-invariant";
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import {
currentSeason,
queryCurrentTeamRating,
queryCurrentUserRating,
} from "~/features/mmr";
import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
HACKY_isInviteOnlyEvent,
HACKY_maxRosterSizeBeforeStart,
} from "~/features/tournament/tournament-utils";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
export const links: LinksFunction = () => {
return [
@ -106,92 +85,77 @@ export const links: LinksFunction = () => {
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const tournament = await tournamentFromDB({ tournamentId, user });
const data = await parseRequestFormData({ request, schema: bracketSchema });
const manager = getTournamentManager("SQL");
validate(isTournamentOrganizer({ user, tournament }));
switch (data._action) {
case "START_TOURNAMENT": {
const hasStarted = hasTournamentStarted(tournamentId);
case "START_BRACKET": {
validate(tournament.isOrganizer(user));
validate(!hasStarted);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");
let teams = findTeamsByTournamentId(tournamentId);
if (checkInHasStarted(tournament)) {
teams = teams.filter(teamHasCheckedIn);
} else {
// in the normal check in process this is handled
teams = teams.filter(
(team) => team.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
);
}
validate(teams.length >= 2, "Not enough teams registered");
validate(bracket.canBeStarted, "Bracket is not ready to be started");
sql.transaction(() => {
manager.create({
const participants = bracket.data.participant.map((p) => p.name);
const stage = manager.create({
tournamentId,
name: resolveTournamentStageName(tournament.format),
type: resolveTournamentStageType(tournament.format),
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
settings: resolveTournamentStageSettings(tournament.format),
name: bracket.name,
type: bracket.type,
seeding:
bracket.type === "round_robin"
? participants
: fillWithNullTillPowerOfTwo(participants),
settings: tournament.bracketSettings(
bracket.type,
participants.length,
),
});
const matches = findAllMatchesByTournamentId(tournamentId);
const matches = findAllMatchesByStageId(stage.id);
// TODO: dynamic best of set when bracket is made
const bestOfs = HACKY_isInviteOnlyEvent(tournament)
const bestOfs = HACKY_isInviteOnlyEvent(tournament.ctx)
? matches.map((match) => [5, match.matchId] as [5, number])
: resolveBestOfs(matches);
: resolveBestOfs(matches, bracket.type);
for (const [bestOf, id] of bestOfs) {
setBestOf({ bestOf, id });
}
})();
return null;
}
case "FINALIZE_TOURNAMENT": {
const bracket = manager.get.tournamentData(tournamentId);
invariant(
bracket.stage.length === 1,
"Bracket doesn't have exactly one stage",
);
const stage = bracket.stage[0];
// TODO: to transaction
// 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.name === BRACKET_NAMES.FINALS,
);
const _everyMatchIsOver = everyMatchIsOver(bracket);
validate(_everyMatchIsOver, "Not every match is over");
let teams = findTeamsByTournamentId(tournamentId);
if (checkInHasStarted(tournament)) {
teams = teams.filter(teamHasCheckedIn);
if (finalStageIdx !== -1) {
await TournamentRepository.checkInMany({
bracketIdx: finalStageIdx,
tournamentTeamIds: tournament.ctx.teams.map((t) => t.id),
});
}
}
const _finalStandings =
finalStandings({
manager,
tournamentId,
includeAll: true,
stageId: stage.id,
}) ?? [];
invariant(
_finalStandings.length === teams.length,
`Final standings length (${_finalStandings.length}) does not match teams length (${teams.length})`,
);
break;
}
case "FINALIZE_TOURNAMENT": {
validate(tournament.canFinalize(user), "Can't finalize tournament");
const _finalStandings = tournament.standings;
const results = allMatchResultsByTournamentId(tournamentId);
invariant(results.length > 0, "No results found");
const season = currentSeason(
databaseTimestampToDate(tournament.startTime),
)?.nth;
const season = currentSeason(tournament.ctx.startTime)?.nth;
addSummary({
tournamentId,
summary: tournamentSummary({
teams,
teams: tournament.ctx.teams,
finalStandings: _finalStandings,
results,
calculateSeasonalStats: typeof season === "number",
@ -208,67 +172,29 @@ export const action: ActionFunction = async ({ params, request }) => {
season,
});
return null;
break;
}
case "BRACKET_CHECK_IN": {
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");
const ownTeam = tournament.ownedTeamByUser(user);
invariant(ownTeam, "User doesn't have owned team");
validate(bracket.canCheckIn(user));
await TournamentRepository.checkIn({
bracketIdx: data.bracketIdx,
tournamentTeamId: ownTeam.id,
});
break;
}
default: {
assertUnreachable(data);
}
}
};
export type TournamentBracketLoaderData = SerializeFrom<typeof loader>;
export const loader = ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const hasStarted = hasTournamentStarted(tournamentId);
const manager = getTournamentManager(hasStarted ? "SQL" : "IN_MEMORY");
if (hasStarted) {
const bracket = manager.get.tournamentData(tournamentId);
invariant(
bracket.stage.length === 1,
"Bracket doesn't have exactly one stage",
);
const stage = bracket.stage[0];
const _everyMatchIsOver = everyMatchIsOver(bracket);
return {
enoughTeams: true,
bracket,
roundBestOfs: bestOfsByTournamentId(tournamentId),
everyMatchIsOver: _everyMatchIsOver,
finalStandings: _everyMatchIsOver
? finalStandings({ manager, tournamentId, stageId: stage.id })
: null,
};
}
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
let teams = findTeamsByTournamentId(tournamentId);
if (checkInHasStarted(tournament)) {
teams = teams.filter(teamHasCheckedIn);
}
const enoughTeams = teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START;
if (enoughTeams) {
manager.create({
tournamentId,
name: resolveTournamentStageName(tournament.format),
type: resolveTournamentStageType(tournament.format),
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
settings: resolveTournamentStageSettings(tournament.format),
});
}
// TODO: use get.stageData
const data = manager.get.tournamentData(tournamentId);
return {
bracket: data,
enoughTeams,
everyMatchIsOver: false,
roundBestOfs: null,
finalStandings: null,
};
return null;
};
export default function TournamentBracketsPage() {
@ -276,17 +202,26 @@ export default function TournamentBracketsPage() {
const visibility = useVisibilityChange();
const { revalidate } = useRevalidator();
const user = useUser();
const data = useLoaderData<typeof loader>();
const [bracketIdx, setBracketIdx] = useSearchParamState({
defaultValue: 0,
name: "idx",
revive: Number,
});
const ref = React.useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const bracket = React.useMemo(
() => tournament.bracketByIdxOrDefault(bracketIdx),
[tournament, bracketIdx],
);
// TODO: bracket i18n
React.useEffect(() => {
if (!data.enoughTeams) return;
if (!bracket.enoughTeams) return;
// matches aren't generated before tournament starts
if (parentRouteData.hasStarted) {
if (!bracket.preview) {
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer.onMatchClicked = (match) => {
// can't view match page of a bye
@ -295,7 +230,7 @@ export default function TournamentBracketsPage() {
}
navigate(
tournamentMatchPage({
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
matchId: match.id,
}),
);
@ -305,10 +240,10 @@ export default function TournamentBracketsPage() {
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer.render(
{
stages: data.bracket.stage,
matches: data.bracket.match,
matchGames: data.bracket.match_game,
participants: data.bracket.participant,
stages: bracket.data.stage,
matches: bracket.data.match,
matchGames: bracket.data.match_game,
participants: bracket.data.participant,
},
{
customRoundName: (info: any) => {
@ -327,8 +262,8 @@ export default function TournamentBracketsPage() {
// my beautiful hack to show seeds
// clean up probably not needed as it's not harmful to append more than one
const cssRulesToAppend = parentRouteData.teams.map((team, i) => {
const participantId = parentRouteData.hasStarted ? team.id : i;
const cssRulesToAppend = tournament.ctx.teams.map((team, i) => {
const participantId = tournament.hasStarted ? team.id : i;
return /* css */ `
[data-participant-id="${participantId}"] {
--seed: "${i + 1} ";
@ -336,15 +271,17 @@ export default function TournamentBracketsPage() {
}
`;
});
if (parentRouteData.teamMemberOfName) {
const ownTeam = tournament.teamMemberOfByUser(user);
if (ownTeam) {
cssRulesToAppend.push(/* css */ `
[title="${parentRouteData.teamMemberOfName}"] {
[title="${ownTeam.name}"] {
--team-text-color: var(--theme-secondary);
}
`);
}
if (data.roundBestOfs) {
for (const { bestOf, roundId } of data.roundBestOfs) {
if (tournament.ctx.bestOfs) {
for (const { bestOf, roundId } of tournament.ctx.bestOfs) {
cssRulesToAppend.push(/* css */ `
[data-round-id="${roundId}"] {
--best-of-text: "Bo${bestOf}";
@ -359,77 +296,70 @@ export default function TournamentBracketsPage() {
if (!element) return;
element.innerHTML = "";
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer!.onMatchClicked = () => {};
};
}, [data, navigate, parentRouteData]);
}, [navigate, bracket, tournament, user]);
React.useEffect(() => {
if (visibility !== "visible" || data.everyMatchIsOver) return;
if (visibility !== "visible" || tournament.everyBracketOver) return;
revalidate();
}, [visibility, revalidate, data.everyMatchIsOver]);
}, [visibility, revalidate, tournament.everyBracketOver]);
const myTeam = parentRouteData.teams.find((team) =>
team.members.some((m) => m.userId === user?.id),
);
const showAddSubsButton =
!tournament.canFinalize(user) &&
!tournament.everyBracketOver &&
tournament.ownedTeamByUser(user) &&
tournament.hasStarted;
const adminCanStart = () => {
// for testing, is always possible to start in development
if (process.env.NODE_ENV === "development") return true;
const waitingForTeamsText = () => {
if (bracketIdx > 0 || tournament.regularCheckInStartInThePast) {
return t("tournament:bracket.waiting.checkin", {
count: TOURNAMENT.ENOUGH_TEAMS_TO_START,
});
}
return (
databaseTimestampToDate(parentRouteData.tournament.startTime).getTime() <
Date.now()
);
return t("tournament:bracket.waiting", {
count: TOURNAMENT.ENOUGH_TEAMS_TO_START,
});
};
const { progress, currentMatchId, currentOpponent } = (() => {
let lowestStatus: Status = Infinity;
let currentMatchId: number | undefined;
let currentOpponent: string | undefined;
if (!myTeam) {
return {
progress: undefined,
currentMatchId: undefined,
currentOpponent: undefined,
};
const teamsSourceText = () => {
if (
tournament.brackets[0].type === "round_robin" &&
!bracket.isUnderground
) {
return `Teams that place in the top ${Math.max(
...(bracket.sources ?? []).flatMap((s) => s.placements),
)} of their group will advance to this stage`;
}
for (const match of data.bracket.match) {
// BYE
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
(match.opponent1.id === myTeam.id ||
match.opponent2.id === myTeam.id) &&
lowestStatus > match.status
) {
lowestStatus = match.status;
currentMatchId = match.id;
const otherTeam =
match.opponent1.id === myTeam.id ? match.opponent2 : match.opponent1;
currentOpponent = parentRouteData.teams.find(
(team) => team.id === otherTeam.id,
)?.name;
}
if (
tournament.brackets[0].type === "round_robin" &&
bracket.isUnderground
) {
return "Teams that don't advance to the final stage can play in this bracket (optional)";
}
return { progress: lowestStatus, currentMatchId, currentOpponent };
})();
if (
tournament.brackets[0].type === "double_elimination" &&
bracket.isUnderground
) {
return `Teams that get eliminated in the first ${Math.abs(
Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
)} rounds of the losers bracket can play in this bracket (optional)`;
}
return null;
};
return (
<div>
{visibility !== "hidden" && !data.everyMatchIsOver ? (
{visibility !== "hidden" && !tournament.everyBracketOver ? (
<AutoRefresher />
) : null}
{data.finalStandings &&
!parentRouteData.hasFinalized &&
isTournamentOrganizer({
user,
tournament: parentRouteData.tournament,
}) ? (
{tournament.canFinalize(user) ? (
<div className="tournament-bracket__finalize">
<FormWithConfirm
dialogHeading={t("tournament:actions.finalize.confirm")}
@ -443,12 +373,10 @@ export default function TournamentBracketsPage() {
</FormWithConfirm>
</div>
) : null}
{!parentRouteData.hasStarted && data.enoughTeams ? (
<Form method="post" className="stack items-center">
{!isTournamentOrganizer({
user,
tournament: parentRouteData.tournament,
}) ? (
{bracket.preview && bracket.enoughTeams ? (
<Form method="post" className="stack items-center mb-4">
<input type="hidden" name="bracketIdx" value={bracketIdx} />
{!tournament.isOrganizer(user) ? (
<Alert
variation="INFO"
alertClassName="tournament-bracket__start-bracket-alert"
@ -463,12 +391,12 @@ export default function TournamentBracketsPage() {
textClassName="stack horizontal md items-center"
>
{t("tournament:bracket.finalize.text")}{" "}
{adminCanStart() ? (
{bracket.canBeStarted ? (
<SubmitButton
variant="outlined"
size="tiny"
testId="finalize-bracket-button"
_action="START_TOURNAMENT"
_action="START_BRACKET"
>
{t("tournament:bracket.finalize.action")}
</SubmitButton>
@ -479,40 +407,43 @@ export default function TournamentBracketsPage() {
}
triggerClassName="tiny outlined"
>
{t("tournament:bracket.beforeStart")}
{bracketIdx === 0
? t("tournament:bracket.beforeStart")
: t("tournament:bracket.waitingForResults")}
</Popover>
)}
</Alert>
)}
</Form>
) : null}
{parentRouteData.hasStarted && progress ? (
<TournamentProgressPrompt
progress={progress}
currentMatchId={currentMatchId}
currentOpponent={currentOpponent}
/>
<div className="stack horizontal sm justify-end">
{bracket.canCheckIn(user) ? (
<BracketCheckinButton bracketIdx={bracketIdx} />
) : null}
{showAddSubsButton ? (
// TODO: could also hide this when team is not in any bracket anymore
<AddSubsPopOver />
) : null}
</div>
{tournament.ctx.isFinalized || tournament.canFinalize(user) ? (
<FinalStandings standings={tournament.standings} />
) : null}
{!data.finalStandings &&
myTeam &&
parentRouteData.hasStarted &&
parentRouteData.ownTeam &&
progress &&
progress < Status.Completed ? (
<AddSubsPopOver
members={myTeam.members}
inviteCode={parentRouteData.ownTeam.inviteCode}
/>
) : null}
{data.finalStandings ? (
<FinalStandings standings={data.finalStandings} />
) : null}
<div className="brackets-viewer" ref={ref}></div>
{!data.enoughTeams ? (
<div className="text-center text-lg font-semi-bold text-lighter">
{t("tournament:bracket.waiting", {
count: TOURNAMENT.ENOUGH_TEAMS_TO_START,
})}
<BracketNav bracketIdx={bracketIdx} setBracketIdx={setBracketIdx} />
<div
className="brackets-viewer"
ref={ref}
data-testid="brackets-viewer"
/>
{!bracket.enoughTeams ? (
<div>
<div className="text-center text-lg font-semi-bold text-lighter mt-6">
{waitingForTeamsText()}
</div>
{bracket.sources ? (
<div className="text-center text-sm font-semi-bold text-lighter mt-2">
{teamsSourceText()}
</div>
) : null}
</div>
) : null}
</div>
@ -537,11 +468,11 @@ function appendStyleTagToHead(content: string) {
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const lastEvent = useEventSource(
tournamentBracketsSubscribePage(parentRouteData.tournament.id),
tournamentBracketsSubscribePage(tournament.ctx.id),
{
event: bracketSubscriptionKey(parentRouteData.tournament.id),
event: bracketSubscriptionKey(tournament.ctx.id),
},
);
@ -576,86 +507,38 @@ function useAutoRefresh() {
}, [lastEvent, revalidate]);
}
function TournamentProgressPrompt({
progress,
currentMatchId,
currentOpponent,
}: {
progress: Status;
currentMatchId?: number;
currentOpponent?: string;
}) {
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<typeof loader>();
if (data.finalStandings) return null;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (progress === Infinity) {
console.error("Unexpected no status");
return null;
}
if (progress === Status.Waiting) {
return (
<TournamentProgressContainer>
<WaitingForMatchText />
</TournamentProgressContainer>
);
}
if (progress >= Status.Completed) {
return (
<TournamentProgressContainer>
{t("tournament:bracket.progress.thanksForPlaying", {
eventName: parentRouteData.tournament.name,
})}
</TournamentProgressContainer>
);
}
if (!currentMatchId || !currentOpponent) {
console.error("Unexpected no match id or opponent");
return null;
}
function BracketCheckinButton({ bracketIdx }: { bracketIdx: number }) {
const fetcher = useFetcher();
return (
<TournamentProgressContainer>
{t("tournament:bracket.progress.match", { opponent: currentOpponent })}
<LinkButton
to={tournamentMatchPage({
matchId: currentMatchId,
eventId: parentRouteData.tournament.id,
})}
<fetcher.Form method="post">
<input type="hidden" name="bracketIdx" value={bracketIdx} />
<SubmitButton
size="tiny"
variant="outlined"
_action="BRACKET_CHECK_IN"
state={fetcher.state}
>
{t("tournament:bracket.progress.match.action")}
</LinkButton>
</TournamentProgressContainer>
Check-in & join the bracket
</SubmitButton>
</fetcher.Form>
);
}
function AddSubsPopOver({
members,
inviteCode,
}: {
members: unknown[];
inviteCode: string;
}) {
const parentRouteData = useOutletContext<TournamentLoaderData>();
function AddSubsPopOver() {
const { t } = useTranslation(["common", "tournament"]);
const [, copyToClipboard] = useCopyToClipboard();
const tournament = useTournament();
const user = useUser();
const ownedTeam = tournament.ownedTeamByUser(user);
invariant(ownedTeam, "User doesn't have owned team");
const subsAvailableToAdd =
HACKY_maxRosterSizeBeforeStart(parentRouteData.tournament) +
1 -
members.length;
tournament.maxTeamMemberCount - ownedTeam.members.length;
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
eventId: parentRouteData.tournament.id,
inviteCode,
eventId: tournament.ctx.id,
inviteCode: ownedTeam.inviteCode,
})}`;
return (
@ -663,7 +546,6 @@ function AddSubsPopOver({
buttonChildren={<>{t("tournament:actions.addSub")}</>}
triggerClassName="tiny outlined ml-auto"
triggerTestId="add-sub-button"
containerClassName="mt-4"
contentClassName="text-xs"
>
{t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
@ -688,9 +570,9 @@ function AddSubsPopOver({
);
}
function FinalStandings({ standings }: { standings: FinalStanding[] }) {
function FinalStandings({ standings }: { standings: Standing[] }) {
const tournament = useTournament();
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [viewAll, setViewAll] = React.useState(false);
if (standings.length < 2) {
@ -701,6 +583,11 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
// eslint-disable-next-line prefer-const
let [first, second, third, ...rest] = standings;
if (third && third.placement === rest[0]?.placement) {
rest.unshift(third);
third = undefined as unknown as Standing;
}
const onlyTwoTeams = !third;
const nonTopThreePlacements = viewAll
@ -714,7 +601,7 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
return (
<div
className="tournament-bracket__standing"
key={standing.tournamentTeam.id}
key={standing.team.id}
data-placement={standing.placement}
>
<div>
@ -722,19 +609,19 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
</div>
<Link
to={tournamentTeamPage({
eventId: parentRouteData.tournament.id,
tournamentTeamId: standing.tournamentTeam.id,
eventId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big"
>
{standing.tournamentTeam.name}
{standing.team.name}
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
{standing.team.members.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
key={player.userId}
className="stack items-center text-xs"
>
<Avatar user={player} size="xxs" />
@ -743,9 +630,9 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
{standing.team.members.map((player) => {
return (
<div key={player.id} className="stack items-center">
<div key={player.userId} className="stack items-center">
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
@ -775,23 +662,23 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
return (
<div
className="tournament-bracket__standing"
key={standing.tournamentTeam.id}
key={standing.team.id}
>
<Link
to={tournamentTeamPage({
eventId: parentRouteData.tournament.id,
tournamentTeamId: standing.tournamentTeam.id,
eventId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament-bracket__standing__team-name"
>
{standing.tournamentTeam.name}
{standing.team.name}
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
{standing.team.members.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
key={player.userId}
className="stack items-center text-xs"
>
<Avatar user={player} size="xxs" />
@ -800,9 +687,12 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
{standing.team.members.map((player) => {
return (
<div key={player.id} className="stack items-center">
<div
key={player.userId}
className="stack items-center"
>
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
@ -842,34 +732,42 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
);
}
function TournamentProgressContainer({
children,
function BracketNav({
bracketIdx,
setBracketIdx,
}: {
children: React.ReactNode;
bracketIdx: number;
setBracketIdx: (bracketIdx: number) => void;
}) {
const tournament = useTournament();
if (tournament.ctx.settings.bracketProgression.length < 2) return null;
return (
<div className="stack items-center">
<div className="tournament-bracket__progress">{children}</div>
</div>
);
}
function WaitingForMatchText() {
const { t } = useTranslation(["tournament"]);
const [showDot, setShowDot] = React.useState(false);
React.useEffect(() => {
const interval = setInterval(() => {
setShowDot((prev) => !prev);
}, 1000);
return () => clearInterval(interval);
});
return (
<div>
{t("tournament:bracket.progress.waiting")}..
<span className={clsx({ invisible: !showDot })}>.</span>
<div className="stack sm horizontal flex-wrap">
{tournament.ctx.settings.bracketProgression.map((bracket, i) => {
// underground bracket was never played despite being in the format
if (
tournament.bracketByIdxOrDefault(i).preview &&
tournament.ctx.isFinalized
) {
return null;
}
return (
<Button
key={bracket.name}
variant="minimal"
onClick={() => setBracketIdx(i)}
className={clsx("text-xs", {
"text-theme underline": bracketIdx === i,
"text-lighter-important": bracketIdx !== i,
})}
>
{bracket.name}
</Button>
);
})}
</div>
);
}

View File

@ -3,12 +3,7 @@ import type {
LinksFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import {
Link,
useLoaderData,
useOutletContext,
useRevalidator,
} from "@remix-run/react";
import { Link, useLoaderData, useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import { nanoid } from "nanoid";
import * as React from "react";
@ -18,19 +13,13 @@ import { Avatar } from "~/components/Avatar";
import { LinkButton } from "~/components/Button";
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
import { sql } from "~/db/sql";
import {
tournamentIdFromParams,
type TournamentLoaderData,
} from "~/features/tournament";
import { requireUser, useUser } from "~/features/auth/core";
import { tournamentIdFromParams } from "~/features/tournament";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { requireUser, useUser } from "~/features/auth/core";
import { getUserId } from "~/features/auth/core/user.server";
import {
canReportTournamentScore,
isTournamentOrganizer,
isTournamentStreamerOrOrganizer,
} from "~/permissions";
import { canReportTournamentScore } from "~/permissions";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import {
@ -39,8 +28,8 @@ import {
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
import { ScoreReporter } from "../components/ScoreReporter";
import { tournamentFromDB } from "../core/Tournament.server";
import { getTournamentManager } from "../core/brackets-manager";
import { emitter } from "../core/emitters.server";
import { resolveMapList } from "../core/mapList.server";
@ -56,8 +45,6 @@ import {
matchSubscriptionKey,
} from "../tournament-bracket-utils";
import bracketStyles from "../tournament-bracket.css";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { logger } from "~/utils/logger";
export const links: LinksFunction = () => [
{
@ -76,9 +63,7 @@ export const action: ActionFunction = async ({ params, request }) => {
});
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const tournament = await tournamentFromDB({ tournamentId, user });
const validateCanReportScore = () => {
const isMemberOfATeamInTheMatch = match.players.some(
@ -87,10 +72,9 @@ export const action: ActionFunction = async ({ params, request }) => {
validate(
canReportTournamentScore({
tournament,
match,
isMemberOfATeamInTheMatch,
user,
isOrganizer: tournament.isOrganizer(user),
}),
"Unauthorized",
401,
@ -118,6 +102,7 @@ export const action: ActionFunction = async ({ params, request }) => {
match.opponentTwo?.id === data.winnerTeamId,
"Winner team id is invalid",
);
validate(match.opponentOne && match.opponentTwo, "Teams are missing");
const mapList =
match.opponentOne?.id && match.opponentTwo?.id
@ -139,6 +124,15 @@ export const action: ActionFunction = async ({ params, request }) => {
validate(false, "Winner team id is invalid");
};
validate(
!data.points ||
(scoreToIncrement() === 0 && data.points[0] > data.points[1]) ||
(scoreToIncrement() === 1 && data.points[1] > data.points[0]),
"Points are invalid (winner must have more points than loser)",
);
// TODO: could also validate that if bracket demands it then points are defined
scores[scoreToIncrement()]++;
sql.transaction(() => {
@ -164,6 +158,8 @@ export const action: ActionFunction = async ({ params, request }) => {
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: String(currentMap.source),
opponentOnePoints: data.points?.[0] ?? null,
opponentTwoPoints: data.points?.[1] ?? null,
});
for (const userId of data.playerIds) {
@ -219,7 +215,6 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
// TODO: bug where you can reopen losers finals after winners finals
case "REOPEN_MATCH": {
const scoreOne = match.opponentOne?.score ?? 0;
const scoreTwo = match.opponentTwo?.score ?? 0;
@ -227,7 +222,11 @@ export const action: ActionFunction = async ({ params, request }) => {
invariant(typeof scoreTwo === "number", "Score two is missing");
invariant(scoreOne !== scoreTwo, "Scores are equal");
validate(isTournamentOrganizer({ tournament, user }));
validate(tournament.isOrganizer(user));
validate(
tournament.matchCanBeReopened(match.id),
"Match can't be reopened, bracket has progressed",
);
const results = findResultsByMatchId(matchId);
const lastResult = results[results.length - 1];
@ -243,30 +242,20 @@ export const action: ActionFunction = async ({ params, request }) => {
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}`,
);
try {
sql.transaction(() => {
deleteTournamentMatchGameResultById(lastResult.id);
manager.update.match({
id: match.id,
opponent1: {
score: scores[0],
result: undefined,
},
opponent2: {
score: scores[1],
result: undefined,
},
});
})();
} catch (err) {
if (!(err instanceof Error)) throw err;
if (err.message.includes("locked")) {
return { error: "locked" };
}
throw err;
}
sql.transaction(() => {
deleteTournamentMatchGameResultById(lastResult.id);
manager.update.match({
id: match.id,
opponent1: {
score: scores[0],
result: undefined,
},
opponent2: {
score: scores[1],
result: undefined,
},
});
})();
break;
}
@ -279,7 +268,7 @@ export const action: ActionFunction = async ({ params, request }) => {
eventId: nanoid(),
userId: user.id,
});
emitter.emit(bracketSubscriptionKey(tournament.id), {
emitter.emit(bracketSubscriptionKey(tournament.ctx.id), {
matchId: match.id,
scores,
isOver:
@ -292,8 +281,7 @@ export const action: ActionFunction = async ({ params, request }) => {
export type TournamentMatchLoaderData = typeof loader;
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
export const loader = ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const matchId = matchIdFromParams(params);
@ -315,53 +303,23 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const currentMap = mapList?.[scoreSum];
const showChat =
match.players.some((p) => p.id === user?.id) ||
isTournamentStreamerOrOrganizer({
user,
tournament: notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
),
});
const matchIsOver =
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
return {
match: {
...match,
chatCode: showChat ? match.chatCode : null,
},
match,
results: findResultsByMatchId(matchId),
seeds: resolveSeeds(),
currentMap,
modes: mapList?.map((map) => map.mode),
matchIsOver,
};
function resolveSeeds() {
const tournamentId = tournamentIdFromParams(params);
const teams = findTeamsByTournamentId(tournamentId);
const teamOneIndex = teams.findIndex(
(team) => team.id === match.opponentOne?.id,
);
const teamTwoIndex = teams.findIndex(
(team) => team.id === match.opponentTwo?.id,
);
return [
teamOneIndex !== -1 ? teamOneIndex + 1 : null,
teamTwoIndex !== -1 ? teamTwoIndex + 1 : null,
];
}
};
export default function TournamentMatchPage() {
const user = useUser();
const visibility = useVisibilityChange();
const { revalidate } = useRevalidator();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
React.useEffect(() => {
@ -370,21 +328,9 @@ export default function TournamentMatchPage() {
revalidate();
}, [visibility, revalidate, data.matchIsOver]);
const isMemberOfATeamInTheMatch = data.match.players.some(
(p) => p.id === user?.id,
);
const type =
canReportTournamentScore({
tournament: parentRouteData.tournament,
match: data.match,
isMemberOfATeamInTheMatch,
user,
}) ||
isTournamentStreamerOrOrganizer({
user,
tournament: parentRouteData.tournament,
})
tournament.canReportScore({ matchId: data.match.id, user }) ||
tournament.isOrganizerOrStreamer(user)
? "EDIT"
: "OTHER";
@ -401,9 +347,14 @@ export default function TournamentMatchPage() {
{!data.matchIsOver && visibility !== "hidden" ? <AutoRefresher /> : null}
<div className="flex horizontal justify-between items-center">
{/* TODO: better title */}
<h2 className="text-lighter text-lg">Match #{data.match.id}</h2>
<h2 className="text-lighter text-lg" data-testid="match-header">
Match #{data.match.id}
</h2>
<LinkButton
to={tournamentBracketsPage(parentRouteData.tournament.id)}
to={tournamentBracketsPage({
tournamentId: tournament.ctx.id,
bracketIdx: tournament.matchIdToBracketIdx(data.match.id),
})}
variant="outlined"
size="tiny"
className="w-max"
@ -439,11 +390,11 @@ function AutoRefresher() {
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const lastEventId = useEventSource(
tournamentMatchSubscribePage({
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
matchId: data.match.id,
}),
{
@ -466,10 +417,10 @@ function MapListSection({
type: "EDIT" | "OTHER";
}) {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]);
const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]);
const teamOne = tournament.teamById(teams[0]);
const teamTwo = tournament.teamById(teams[1]);
if (!teamOne || !teamTwo) return null;
@ -488,7 +439,7 @@ function MapListSection({
function ResultsSection() {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const [selectedResultIndex, setSelectedResultIndex] = useSearchParamState({
defaultValue: data.results.length - 1,
name: "result",
@ -504,12 +455,12 @@ function ResultsSection() {
const result = data.results[selectedResultIndex];
invariant(result, "Result is missing");
const teamOne = parentRouteData.teams.find(
(team) => team.id === data.match.opponentOne?.id,
);
const teamTwo = parentRouteData.teams.find(
(team) => team.id === data.match.opponentTwo?.id,
);
const teamOne = data.match.opponentOne?.id
? tournament.teamById(data.match.opponentOne.id)
: undefined;
const teamTwo = data.match.opponentTwo?.id
? tournament.teamById(data.match.opponentTwo.id)
: undefined;
if (!teamOne || !teamTwo) {
throw new Error("Team is missing");
@ -534,10 +485,10 @@ function Rosters({
teams: [id: number | null | undefined, id: number | null | undefined];
}) {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]);
const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]);
const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined;
const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined;
const teamOnePlayers = data.match.players.filter(
(p) => p.tournamentTeamId === teamOne?.id,
);
@ -560,7 +511,7 @@ function Rosters({
{teamOne ? (
<Link
to={tournamentTeamPage({
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
tournamentTeamId: teamOne.id,
})}
className="text-main-forced font-bold"
@ -595,7 +546,7 @@ function Rosters({
{teamTwo ? (
<Link
to={tournamentTeamPage({
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
tournamentTeamId: teamTwo.id,
})}
className="text-main-forced font-bold"

View File

@ -16,12 +16,35 @@ const reportedMatchPosition = z.preprocess(
.max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1),
);
const point = z.number().int().min(0).max(100);
export const matchSchema = z.union([
z.object({
_action: _action("REPORT_SCORE"),
winnerTeamId: id,
position: reportedMatchPosition,
playerIds: reportedMatchPlayerIds,
points: z.preprocess(
safeJSONParse,
z
.tuple([point, point])
.nullish()
.refine(
(val) => {
if (!val) return true;
const [p1, p2] = val;
if (p1 === p2) return false;
if (p1 === 100 && p2 !== 0) return false;
if (p2 === 100 && p1 !== 0) return false;
return true;
},
{
message:
"Invalid points. Must not be equal & if one is 100, the other must be 0.",
},
),
),
}),
z.object({
_action: _action("UNDO_REPORT_SCORE"),
@ -32,11 +55,18 @@ export const matchSchema = z.union([
}),
]);
export const bracketIdx = z.coerce.number().int().min(0).max(2);
export const bracketSchema = z.union([
z.object({
_action: _action("START_TOURNAMENT"),
_action: _action("START_BRACKET"),
bracketIdx,
}),
z.object({
_action: _action("FINALIZE_TOURNAMENT"),
}),
z.object({
_action: _action("BRACKET_CHECK_IN"),
bracketIdx,
}),
]);

View File

@ -1,23 +1,14 @@
import type { Stage } from "~/modules/brackets-model";
import type {
TournamentFormat,
TournamentMatch,
TournamentStage,
} from "~/db/types";
import {
sourceTypes,
seededRandom,
} from "~/modules/tournament-map-list-generator";
import { assertUnreachable } from "~/utils/types";
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "~/features/tournament";
import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
import type { TournamentMatch } from "~/db/types";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import { HACKY_isInviteOnlyEvent } from "../tournament/tournament-utils";
import {
seededRandom,
sourceTypes,
} from "~/modules/tournament-map-list-generator";
import { removeDuplicates } from "~/utils/arrays";
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
import type { TournamentDataTeam } from "./core/Tournament.server";
export function matchIdFromParams(params: Params<string>) {
const result = Number(params["mid"]);
@ -57,7 +48,7 @@ export function resolveRoomPass(matchId: TournamentMatch["id"]) {
}
export function resolveHostingTeam(
teams: [TournamentLoaderTeam, TournamentLoaderTeam],
teams: [TournamentDataTeam, TournamentDataTeam],
) {
if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1];
if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0];
@ -71,47 +62,6 @@ export function resolveHostingTeam(
return teams[0];
}
export function resolveTournamentStageName(format: TournamentFormat) {
switch (format) {
case "SE":
case "DE":
return "Elimination stage";
default: {
assertUnreachable(format);
}
}
}
export function resolveTournamentStageType(
format: TournamentFormat,
): TournamentStage["type"] {
switch (format) {
case "SE":
return "single_elimination";
case "DE":
return "double_elimination";
default: {
assertUnreachable(format);
}
}
}
export function resolveTournamentStageSettings(
format: TournamentFormat,
): Stage["settings"] {
switch (format) {
case "SE":
return {};
case "DE":
return {
grandFinal: "double",
};
default: {
assertUnreachable(format);
}
}
}
export function mapCountPlayedInSetWithCertainty({
bestOf,
scores,
@ -142,23 +92,6 @@ export function checkSourceIsValid({
return false;
}
export function HACKY_resolvePoolCode({
event,
hostingTeamId,
}: {
event: TournamentLoaderData["tournament"];
hostingTeamId: number;
}) {
const prefix = event.name.includes("In The Zone")
? "ITZ"
: HACKY_isInviteOnlyEvent(event)
? "SQ"
: "PN";
const lastDigit = hostingTeamId % 10;
return { prefix, lastDigit };
}
export function bracketSubscriptionKey(tournamentId: number) {
return `BRACKET_CHANGED_${tournamentId}`;
}
@ -174,7 +107,13 @@ export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
return [...arr, ...new Array(nullsToAdd).fill(null)];
}
export function everyMatchIsOver(bracket: ValueToArray<DataTypes>) {
export function everyMatchIsOver(
bracket: Pick<ValueToArray<DataTypes>, "match">,
) {
// winners, losers & grand finals+bracket reset are all different stages
const isDoubleElimination =
removeDuplicates(bracket.match.map((match) => match.group_id)).length === 3;
// tournament didn't start yet
if (bracket.match.length === 0) return false;
@ -182,7 +121,7 @@ export function everyMatchIsOver(bracket: ValueToArray<DataTypes>) {
for (const [i, match] of bracket.match.entries()) {
// special case - bracket reset might not be played depending on who wins in the grands
const isLast = i === bracket.match.length - 1;
if (isLast && lastWinner === 1) {
if (isLast && lastWinner === 1 && isDoubleElimination) {
continue;
}
// BYE
@ -201,3 +140,22 @@ export function everyMatchIsOver(bracket: ValueToArray<DataTypes>) {
return true;
}
export function everyBracketOver(tournament: ValueToArray<DataTypes>) {
const stageIds = tournament.stage.map((stage) => stage.id);
for (const stageId of stageIds) {
const matches = tournament.match.filter(
(match) => match.stage_id === stageId,
);
if (!everyMatchIsOver({ match: matches })) {
return false;
}
}
return true;
}
export const bracketHasStarted = (bracket: ValueToArray<DataTypes>) =>
bracket.stage[0] && bracket.stage[0].id !== 0;

View File

@ -303,6 +303,12 @@
height: 8px;
}
.tournament-bracket__points-input {
--input-width: 4.5rem;
padding: var(--s-3-5) var(--s-2) !important;
font-size: var(--fonts-sm);
}
.tournament-bracket__progress {
display: flex;
align-items: center;
@ -322,6 +328,7 @@
gap: var(--s-2);
max-width: max-content;
margin: 0 auto;
margin-bottom: var(--s-4);
}
[data-placement="1"] {
order: -1;

View File

@ -1,37 +1,33 @@
import {
type TournamentLoaderData,
tournamentIdFromParams,
} from "~/features/tournament";
import {
type SubByTournamentId,
findSubsByTournamentId,
} from "../queries/findSubsByTournamentId.server";
import {
redirect,
type ActionFunction,
type LinksFunction,
type LoaderFunctionArgs,
} from "@remix-run/node";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import { getUser, requireUser, useUser } from "~/features/auth/core";
import { assertUnreachable } from "~/utils/types";
import styles from "../tournament-subs.css";
import { Avatar } from "~/components/Avatar";
import { discordFullName } from "~/utils/strings";
import { tournamentRegisterPage, userPage } from "~/utils/urls";
import { WeaponImage } from "~/components/Image";
import { Flag } from "~/components/Flag";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { Button, LinkButton } from "~/components/Button";
import { deleteSub } from "../queries/deleteSub.server";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TrashIcon } from "~/components/icons/Trash";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Button, LinkButton } from "~/components/Button";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { Redirect } from "~/components/Redirect";
import { notFoundIfFalsy } from "~/utils/remix";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { HACKY_subsFeatureEnabled } from "~/features/tournament/tournament-utils";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { TrashIcon } from "~/components/icons/Trash";
import { getUser, requireUser, useUser } from "~/features/auth/core";
import { tournamentIdFromParams } from "~/features/tournament";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { discordFullName } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { tournamentRegisterPage, userPage } from "~/utils/urls";
import { deleteSub } from "../queries/deleteSub.server";
import {
findSubsByTournamentId,
type SubByTournamentId,
} from "../queries/findSubsByTournamentId.server";
import styles from "../tournament-subs.css";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
export const links: LinksFunction = () => {
return [
@ -58,10 +54,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = await getUser(request);
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
if (!HACKY_subsFeatureEnabled(tournament)) {
const tournament = await tournamentFromDB({ tournamentId, user });
if (!tournament.subsFeatureEnabled) {
throw redirect(tournamentRegisterPage(tournamentId));
}
@ -100,17 +94,15 @@ export default function TournamentSubsPage() {
const user = useUser();
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
if (parentRouteData.hasFinalized) {
return (
<Redirect to={tournamentRegisterPage(parentRouteData.tournament.id)} />
);
if (tournament.everyBracketOver) {
return <Redirect to={tournamentRegisterPage(tournament.ctx.id)} />;
}
return (
<div className="stack lg">
{!parentRouteData.teamMemberOfName && user ? (
{!tournament.teamMemberOfByUser(user) && user ? (
<div className="stack items-end">
<LinkButton to="new" size="tiny">
{data.hasOwnSubPost

View File

@ -2,12 +2,11 @@ import type { NotNull } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
export type FindById = NonNullable<Unwrapped<typeof findById>>;
export async function findById(id: number) {
const row = await db
const result = await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
@ -15,33 +14,22 @@ export async function findById(id: number) {
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(({ eb }) => [
.select(({ eb, exists, selectFrom }) => [
"Tournament.id",
"Tournament.mapPickingStyle",
"Tournament.format",
"CalendarEvent.id as eventId",
"Tournament.settings",
"Tournament.showMapListGenerator",
"Tournament.castTwitchAccounts",
"CalendarEvent.id as eventId",
"Tournament.mapPickingStyle",
"CalendarEvent.name",
"CalendarEvent.description",
"CalendarEvent.bracketUrl",
"CalendarEventDate.startTime",
jsonObjectFrom(
eb
.selectFrom("User")
.whereRef("CalendarEvent.authorId", "=", "User.id")
.select([...COMMON_USER_FIELDS, userChatNameColor]),
.select([...COMMON_USER_FIELDS, userChatNameColor])
.whereRef("User.id", "=", "CalendarEvent.authorId"),
).as("author"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.whereRef(
"MapPoolMap.tieBreakerCalendarEventId",
"=",
"CalendarEvent.id",
)
.select(["MapPoolMap.stageId", "MapPoolMap.mode"]),
).as("tieBreakerMapPool"),
jsonArrayFrom(
eb
.selectFrom("TournamentStaff")
@ -53,14 +41,230 @@ export async function findById(id: number) {
])
.where("TournamentStaff.tournamentId", "=", id),
).as("staff"),
exists(
selectFrom("TournamentResult")
.where("TournamentResult.tournamentId", "=", id)
.select("TournamentResult.tournamentId"),
).as("isFinalized"),
jsonArrayFrom(
eb
.selectFrom("TournamentStage")
.select([
"TournamentStage.id",
"TournamentStage.name",
"TournamentStage.type",
])
.where("TournamentStage.tournamentId", "=", id)
.orderBy("TournamentStage.number asc"),
).as("inProgressBrackets"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
.select(({ eb: innerEb }) => [
"TournamentTeam.id",
"TournamentTeam.name",
"TournamentTeam.seed",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
jsonArrayFrom(
innerEb
.selectFrom("TournamentTeamMember")
.innerJoin("User", "TournamentTeamMember.userId", "User.id")
.leftJoin("PlusTier", "User.id", "PlusTier.userId")
.select([
"User.id as userId",
"User.discordName",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"User.inGameName",
"User.country",
"PlusTier.tier as plusTier",
"TournamentTeamMember.isOwner",
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.orderBy("TournamentTeamMember.createdAt asc"),
).as("members"),
jsonArrayFrom(
innerEb
.selectFrom("TournamentTeamCheckIn")
.select([
"TournamentTeamCheckIn.bracketIdx",
"TournamentTeamCheckIn.checkedInAt",
])
.whereRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
),
).as("checkIns"),
jsonArrayFrom(
innerEb
.selectFrom("MapPoolMap")
.whereRef(
"MapPoolMap.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.select(["MapPoolMap.stageId", "MapPoolMap.mode"]),
).as("mapPool"),
])
.where("TournamentTeam.tournamentId", "=", id)
.orderBy(["TournamentTeam.seed asc", "TournamentTeam.createdAt asc"]),
).as("teams"),
jsonArrayFrom(
eb
.selectFrom("TournamentRound")
.innerJoin(
"TournamentMatch",
"TournamentMatch.roundId",
"TournamentRound.id",
)
.innerJoin(
"TournamentStage",
"TournamentRound.stageId",
"TournamentStage.id",
)
.select(["TournamentRound.id as roundId", "TournamentMatch.bestOf"])
.groupBy("roundId")
.where("TournamentStage.tournamentId", "=", id),
).as("bestOfs"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.stageId", "MapPoolMap.mode"])
.whereRef(
"MapPoolMap.tieBreakerCalendarEventId",
"=",
"CalendarEvent.id",
),
).as("tieBreakerMapPool"),
jsonArrayFrom(
eb
.selectFrom("TournamentStage")
.innerJoin(
"TournamentMatch",
"TournamentMatch.stageId",
"TournamentStage.id",
)
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.innerJoin(
"TournamentMatchGameResultParticipant",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.select("TournamentMatchGameResultParticipant.userId")
.groupBy("TournamentMatchGameResultParticipant.userId")
.where("TournamentStage.tournamentId", "=", id),
).as("participatedUsers"),
])
.where("Tournament.id", "=", id)
.$narrowType<{ author: NotNull }>()
.executeTakeFirst();
if (!row) return null;
if (!result) return null;
return row;
return {
...result,
participatedUsers: result.participatedUsers.map((user) => user.userId),
};
}
export async function findCastTwitchAccountsByTournamentId(
tournamentId: number,
) {
const result = await db
.selectFrom("Tournament")
.select("castTwitchAccounts")
.where("id", "=", tournamentId)
.executeTakeFirst();
if (!result) return null;
return result.castTwitchAccounts;
}
export function checkedInTournamentTeamsByBracket({
tournamentId,
bracketIdx,
}: {
tournamentId: number;
bracketIdx: number;
}) {
return db
.selectFrom("TournamentTeamCheckIn")
.innerJoin(
"TournamentTeam",
"TournamentTeamCheckIn.tournamentTeamId",
"TournamentTeam.id",
)
.select(["TournamentTeamCheckIn.tournamentTeamId"])
.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx)
.where("TournamentTeam.tournamentId", "=", tournamentId)
.execute();
}
export function checkIn({
tournamentTeamId,
bracketIdx,
}: {
tournamentTeamId: number;
bracketIdx: number | null;
}) {
return db
.insertInto("TournamentTeamCheckIn")
.values({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
})
.execute();
}
export function checkOut({
tournamentTeamId,
bracketIdx,
}: {
tournamentTeamId: number;
bracketIdx: number | null;
}) {
let query = db
.deleteFrom("TournamentTeamCheckIn")
.where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId);
if (typeof bracketIdx === "number") {
query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx);
}
return query.execute();
}
export function checkInMany({
tournamentTeamIds,
bracketIdx,
}: {
tournamentTeamIds: number[];
bracketIdx: number;
}) {
return db
.insertInto("TournamentTeamCheckIn")
.values(
tournamentTeamIds.map((tournamentTeamId) => ({
checkedInAt: dateToDatabaseTimestamp(new Date()),
tournamentTeamId,
bracketIdx,
})),
)
.execute();
}
export function addStaff({

View File

@ -1,10 +1,10 @@
import { Link } from "@remix-run/react";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
import { Avatar } from "~/components/Avatar";
import { userPage } from "~/utils/urls";
import { ModeImage, StageImage } from "~/components/Image";
import clsx from "clsx";
import { Avatar } from "~/components/Avatar";
import { ModeImage, StageImage } from "~/components/Image";
import type { MapPoolMap, User } from "~/db/types";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { userPage } from "~/utils/urls";
export function TeamWithRoster({
team,
@ -13,7 +13,7 @@ export function TeamWithRoster({
teamPageUrl,
activePlayers,
}: {
team: Pick<FindTeamsByTournamentIdItem, "members" | "name">;
team: TournamentDataTeam;
mapPool?: Array<Pick<MapPoolMap, "stageId" | "mode">> | null;
seed?: number;
teamPageUrl?: string;

View File

@ -4,19 +4,22 @@ import {
type SetHistoryByTeamIdItem,
setHistoryByTeamId,
} from "../queries/setHistoryByTeamId.server";
import { findRoundNumbersByTournamentId } from "../queries/findRoundNumbersByTournamentId.server";
import { findRoundsByTournamentId } from "../queries/findRoundsByTournamentId.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import { sourceTypes } from "~/modules/tournament-map-list-generator";
import invariant from "tiny-invariant";
import type { Tables } from "~/db/tables";
import { logger } from "~/utils/logger";
import { BRACKET_NAMES } from "../tournament-constants";
export interface PlayedSet {
tournamentMatchId: number;
score: [teamBeingViewed: number, opponent: number];
round: {
type: "winners" | "losers" | "single_elim";
type: "winners" | "losers" | "single_elim" | "round_robin";
round: number | "finals" | "grand_finals" | "bracket_reset";
};
bracket: "main" | "underground";
stageName: string;
maps: Array<{
stageId: StageId;
modeShort: ModeShort;
@ -84,9 +87,12 @@ export function tournamentTeamSets({
tournamentId: number;
}): PlayedSet[] {
const sets = setHistoryByTeamId(tournamentTeamId);
const allRoundNumbers = findRoundNumbersByTournamentId(tournamentId);
const allRounds = findRoundsByTournamentId(tournamentId);
return sets.map((set) => {
const round =
allRounds.find((round) => round.stageId === set.stageId) ?? allRounds[0];
const resolveRound = () => {
if (set.groupNumber === 3) {
if (set.roundNumber === 2) return "bracket_reset";
@ -94,24 +100,35 @@ export function tournamentTeamSets({
return "grand_finals";
}
// TODO: also consider stageId
const maxRoundNumberOfGroup = Math.max(
...allRoundNumbers
.filter((round) => round.groupNumber === set.groupNumber)
...allRounds
.filter(
(round) =>
round.groupNumber === set.groupNumber &&
round.stageId === set.stageId,
)
.map((round) => round.roundNumber),
);
if (set.roundNumber === maxRoundNumberOfGroup) return "finals";
if (
round.stageName !== BRACKET_NAMES.GROUPS &&
set.roundNumber === maxRoundNumberOfGroup
) {
return "finals";
}
return set.roundNumber;
};
return {
tournamentMatchId: set.tournamentMatchId,
bracket: "main",
stageName: round.stageName,
round: {
round: resolveRound(),
type: resolveRoundType({ groupNumber: set.groupNumber }),
type: resolveRoundType({
groupNumber: set.groupNumber,
stageType: round.stageType,
}),
},
maps: set.matches.map((match) => ({
stageId: match.stageId,
@ -161,8 +178,21 @@ function flipScoreIfNeeded(set: SetHistoryByTeamIdItem): [number, number] {
return score;
}
// TODO: this only works for DE
function resolveRoundType({ groupNumber }: { groupNumber: number }) {
function resolveRoundType({
groupNumber,
stageType,
}: {
groupNumber: number;
stageType: Tables["TournamentStage"]["type"];
}) {
if (stageType === "single_elimination") {
return "single_elim";
}
if (stageType === "round_robin") {
return "round_robin";
}
if (groupNumber === 1 || groupNumber === 3) {
return "winners";
}
@ -171,6 +201,8 @@ function resolveRoundType({ groupNumber }: { groupNumber: number }) {
return "losers";
}
// TODO: resolve this correctly
logger.warn(
`resolveRoundType: groupNumber ${groupNumber} and stageType ${stageType} not handled`,
);
return "single_elim";
}

View File

@ -1,11 +1,2 @@
export { TOURNAMENT } from "./tournament-constants";
export type {
TournamentLoaderTeam,
TournamentLoaderData,
} from "./routes/to.$id";
export {
tournamentIdFromParams,
modesIncluded,
checkInHasStarted,
teamHasCheckedIn,
} from "./tournament-utils";
export { tournamentIdFromParams, modesIncluded } from "./tournament-utils";

View File

@ -1,4 +1,5 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type {
CalendarEvent,
CalendarEventDate,
@ -10,7 +11,7 @@ const stm = sql.prepare(/*sql*/ `
select
"Tournament"."id",
"Tournament"."mapPickingStyle",
"Tournament"."format",
"Tournament"."settings",
"Tournament"."showMapListGenerator",
"CalendarEvent"."id" as "eventId",
"CalendarEvent"."name",
@ -33,12 +34,11 @@ type FindByIdentifierRow = (Pick<
CalendarEvent,
"bracketUrl" | "name" | "description" | "authorId"
> &
Pick<
Tournament,
"id" | "format" | "mapPickingStyle" | "showMapListGenerator"
> &
Pick<Tournament, "id" | "mapPickingStyle" | "showMapListGenerator"> &
Pick<User, "discordId" | "discordName" | "discordDiscriminator"> &
Pick<CalendarEventDate, "startTime">) & { eventId: CalendarEvent["id"] };
Pick<CalendarEventDate, "startTime">) & {
eventId: CalendarEvent["id"];
} & { settings: string };
export function findByIdentifier(identifier: string | number) {
const rows = stm.all({ identifier }) as FindByIdentifierRow[];
@ -50,6 +50,9 @@ export function findByIdentifier(identifier: string | number) {
return {
...rest,
settings: JSON.parse(
tournament.settings,
) as Tables["Tournament"]["settings"],
author: {
discordId,
discordName,

View File

@ -1,8 +1,11 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
select
"TournamentStage"."id" as "stageId",
"TournamentStage"."name" as "stageName",
"TournamentStage"."type" as "stageType",
"TournamentRound"."number" as "roundNumber",
"TournamentGroup"."number" as "groupNumber"
from "TournamentStage"
@ -12,9 +15,11 @@ const stm = sql.prepare(/* sql */ `
group by "TournamentStage"."id", "TournamentRound"."number", "TournamentGroup"."number"
`);
export function findRoundNumbersByTournamentId(tournamentId: number) {
export function findRoundsByTournamentId(tournamentId: number) {
return stm.all({ tournamentId }) as Array<{
stageId: number;
stageName: string;
stageType: Tables["TournamentStage"]["type"];
roundNumber: number;
groupNumber: number;
}>;

View File

@ -40,7 +40,7 @@ const stm = sql.prepare(/*sql*/ `
) as "members"
from
"TournamentTeam"
left join "TournamentTeamCheckIn" on "TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id"
left join "TournamentTeamCheckIn" on "TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id" and "TournamentTeamCheckIn"."bracketIdx" is null
left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id"
left join "User" on "User"."id" = "TournamentTeamMember"."userId"
left join "PlusTier" on "User"."id" = "PlusTier"."userId"

View File

@ -13,6 +13,7 @@ const stm = sql.prepare(/* sql */ `
"otherTeam"."name" as "otherTeamName",
"otherTeam"."id" as "otherTeamId",
"round"."number" as "roundNumber",
"round"."stageId" as "stageId",
"group"."number" as "groupNumber",
json_group_array(
json_object(
@ -84,6 +85,7 @@ export interface SetHistoryByTeamIdItem {
otherTeamName: string;
otherTeamId: number;
roundNumber: number;
stageId: number;
groupNumber: number;
matches: {
stageId: StageId;

View File

@ -1,53 +1,42 @@
import type { ActionFunction } from "@remix-run/node";
import { useFetcher, useOutletContext, useSubmit } from "@remix-run/react";
import { useFetcher, useSubmit } from "@remix-run/react";
import * as React from "react";
import { Button, LinkButton } from "~/components/Button";
import { Toggle } from "~/components/Toggle";
import { useTranslation } from "react-i18next";
import {
isTournamentAdmin,
isAdmin,
isTournamentOrganizer,
} from "~/permissions";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { updateShowMapListGenerator } from "../queries/updateShowMapListGenerator.server";
import { requireUserId } from "~/features/auth/core/user.server";
import {
HACKY_resolveCheckInTime,
tournamentIdFromParams,
validateCanCheckIn,
} from "../tournament-utils";
import { SubmitButton } from "~/components/SubmitButton";
import { adminActionSchema } from "../tournament-schemas.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import invariant from "tiny-invariant";
import { assertUnreachable } from "~/utils/types";
import { checkIn } from "../queries/checkIn.server";
import { checkOut } from "../queries/checkOut.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import type { TournamentLoaderData } from "./to.$id";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { Avatar } from "~/components/Avatar";
import { Button, LinkButton } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Redirect } from "~/components/Redirect";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { UserSearch } from "~/components/UserSearch";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core";
import { requireUserId } from "~/features/auth/core/user.server";
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { isAdmin } from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import {
calendarEditPage,
calendarEventPage,
tournamentPage,
} from "~/utils/urls";
import { Redirect } from "~/components/Redirect";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
import { UserSearch } from "~/components/UserSearch";
import * as TournamentRepository from "../TournamentRepository.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import { createTeam } from "../queries/createTeam.server";
import { Divider } from "~/components/Divider";
import { Avatar } from "~/components/Avatar";
import { TrashIcon } from "~/components/icons/Trash";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { databaseTimestampToDate } from "~/utils/dates";
import { deleteTeam } from "../queries/deleteTeam.server";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { updateShowMapListGenerator } from "../queries/updateShowMapListGenerator.server";
import { adminActionSchema } from "../tournament-schemas.server";
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournament } from "./to.$id";
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
@ -57,25 +46,22 @@ export const action: ActionFunction = async ({ request, params }) => {
});
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const teams = findTeamsByTournamentId(tournament.id);
const tournament = await tournamentFromDB({ tournamentId, user });
const validateIsTournamentAdmin = () =>
validate(isTournamentAdmin({ user, tournament }), "Unauthorized", 401);
validate(tournament.isAdmin(user), "Unauthorized", 401);
const validateIsTournamentOrganizer = () =>
validate(isTournamentOrganizer({ user, tournament }), "Unauthorized", 401);
validate(tournament.isOrganizer(user), "Unauthorized", 401);
switch (data._action) {
case "ADD_TEAM": {
validateIsTournamentOrganizer();
validate(
teams.every((t) => t.name !== data.teamName),
tournament.ctx.teams.every((t) => t.name !== data.teamName),
"Team name taken",
);
validate(
teams.every((t) => t.members.every((m) => m.userId !== data.userId)),
!tournament.teamMemberOfByUser({ id: data.userId }),
"User already on a team",
);
@ -91,14 +77,14 @@ export const action: ActionFunction = async ({ request, params }) => {
case "UPDATE_SHOW_MAP_LIST_GENERATOR": {
validateIsTournamentAdmin();
updateShowMapListGenerator({
tournamentId: tournament.id,
tournamentId: tournament.ctx.id,
showMapListGenerator: Number(data.show),
});
break;
}
case "CHANGE_TEAM_OWNER": {
validateIsTournamentOrganizer();
const team = teams.find((t) => t.id === data.teamId);
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");
@ -115,31 +101,53 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "CHECK_IN": {
validateIsTournamentOrganizer();
const team = teams.find((t) => t.id === data.teamId);
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validateCanCheckIn({
event: tournament,
team,
mapPool: findMapPoolByTeamId(team.id),
});
validate(
data.bracketIdx !== 0 ||
tournament.checkInConditionsFulfilled({
tournamentTeamId: team.id,
mapPool: findMapPoolByTeamId(team.id),
}),
"Can't check-in",
);
checkIn(team.id);
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 = teams.find((t) => t.id === data.teamId);
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!hasTournamentStarted(tournament.id), "Tournament has started");
validate(
data.bracketIdx !== 0 || !tournament.hasStarted,
"Tournament has started",
);
checkOut(team.id);
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,
});
break;
}
case "REMOVE_MEMBER": {
validateIsTournamentOrganizer();
const team = teams.find((t) => t.id === data.teamId);
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!team.checkedInAt, "Team is checked in");
validate(team.checkIns.length === 0, "Team is checked in");
validate(
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
@ -156,16 +164,14 @@ export const action: ActionFunction = async ({ request, params }) => {
// to add members from a checked in team
case "ADD_MEMBER": {
validateIsTournamentOrganizer();
const team = teams.find((t) => t.id === data.teamId);
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
const previousTeam = teams.find((t) =>
t.members.some((m) => m.userId === data.userId),
);
const previousTeam = tournament.teamMemberOfByUser({ id: data.userId });
if (hasTournamentStarted(tournament.id)) {
if (tournament.hasStarted) {
validate(
!previousTeam || !previousTeam.checkedInAt,
!previousTeam || previousTeam.checkIns.length === 0,
"User is already on a checked in team",
);
} else {
@ -184,9 +190,9 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "DELETE_TEAM": {
validateIsTournamentOrganizer();
const team = teams.find((t) => t.id === data.teamId);
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!hasTournamentStarted(tournament.id), "Tournament has started");
validate(!tournament.hasStarted, "Tournament has started");
deleteTeam(team.id);
break;
@ -195,7 +201,7 @@ export const action: ActionFunction = async ({ request, params }) => {
validateIsTournamentAdmin();
await TournamentRepository.addStaff({
role: data.role,
tournamentId: tournament.id,
tournamentId: tournament.ctx.id,
userId: data.userId,
});
break;
@ -203,7 +209,7 @@ export const action: ActionFunction = async ({ request, params }) => {
case "REMOVE_STAFF": {
validateIsTournamentAdmin();
await TournamentRepository.removeStaff({
tournamentId: tournament.id,
tournamentId: tournament.ctx.id,
userId: data.userId,
});
break;
@ -211,7 +217,7 @@ export const action: ActionFunction = async ({ request, params }) => {
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
validateIsTournamentOrganizer();
await TournamentRepository.updateCastTwitchAccounts({
tournamentId: tournament.id,
tournamentId: tournament.ctx.id,
castTwitchAccounts: data.castTwitchAccounts,
});
break;
@ -224,54 +230,50 @@ export const action: ActionFunction = async ({ request, params }) => {
return null;
};
// xxx: download participants of certain bracket (not checked in probably most relevant)
// TODO: translations
export default function TournamentAdminPage() {
const { t } = useTranslation(["calendar"]);
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const user = useUser();
if (
!isTournamentOrganizer({ user, tournament: data.tournament }) ||
data.hasFinalized
) {
return <Redirect to={tournamentPage(data.tournament.id)} />;
if (!tournament.isOrganizer(user) || tournament.everyBracketOver) {
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
}
return (
<div className="stack lg">
{isTournamentAdmin({ user, tournament: data.tournament }) ? (
{tournament.isAdmin(user) && !tournament.hasStarted ? (
<div className="stack horizontal items-end">
<LinkButton
to={calendarEditPage(data.tournament.eventId)}
to={calendarEditPage(tournament.ctx.eventId)}
size="tiny"
variant="outlined"
>
Edit event info
</LinkButton>
{!data.hasStarted ? (
<FormWithConfirm
dialogHeading={t("calendar:actions.delete.confirm", {
name: data.tournament.name,
})}
action={calendarEventPage(data.tournament.eventId)}
submitButtonTestId="delete-submit-button"
<FormWithConfirm
dialogHeading={t("calendar:actions.delete.confirm", {
name: tournament.ctx.name,
})}
action={calendarEventPage(tournament.ctx.eventId)}
submitButtonTestId="delete-submit-button"
>
<Button
className="ml-auto"
size="tiny"
variant="minimal-destructive"
type="submit"
>
<Button
className="ml-auto"
size="tiny"
variant="minimal-destructive"
type="submit"
>
{t("calendar:actions.delete")}
</Button>
</FormWithConfirm>
) : null}
{t("calendar:actions.delete")}
</Button>
</FormWithConfirm>
</div>
) : null}
<Divider smallText>Team actions</Divider>
<TeamActions />
{isTournamentAdmin({ user, tournament: data.tournament }) ? (
{tournament.isAdmin(user) ? (
<>
<Divider smallText>Staff</Divider>
<Staff />
@ -286,7 +288,12 @@ export default function TournamentAdminPage() {
);
}
type Input = "TEAM_NAME" | "REGISTERED_TEAM" | "USER" | "ROSTER_MEMBER";
type Input =
| "TEAM_NAME"
| "REGISTERED_TEAM"
| "USER"
| "ROSTER_MEMBER"
| "BRACKET";
const actions = [
{
type: "ADD_TEAM",
@ -300,13 +307,13 @@ const actions = [
},
{
type: "CHECK_IN",
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"],
inputs: ["REGISTERED_TEAM", "BRACKET"] as Input[],
when: ["CHECK_IN_STARTED"],
},
{
type: "CHECK_OUT",
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"],
inputs: ["REGISTERED_TEAM", "BRACKET"] as Input[],
when: ["CHECK_IN_STARTED"],
},
{
type: "ADD_MEMBER",
@ -328,29 +335,28 @@ const actions = [
function TeamActions() {
const fetcher = useFetcher();
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [selectedTeamId, setSelectedTeamId] = React.useState(data.teams[0]?.id);
const tournament = useTournament();
const [selectedTeamId, setSelectedTeamId] = React.useState(
tournament.ctx.teams[0]?.id,
);
const [selectedAction, setSelectedAction] = React.useState<
(typeof actions)[number]
>(actions[0]);
const selectedTeam = data.teams.find((team) => team.id === selectedTeamId);
const selectedTeam = tournament.teamById(selectedTeamId);
const actionsToShow = actions.filter((action) => {
for (const when of action.when) {
switch (when) {
case "CHECK_IN_STARTED": {
if (
HACKY_resolveCheckInTime(data.tournament).getTime() > Date.now()
) {
if (!tournament.regularCheckInStartInThePast) {
return false;
}
break;
}
case "TOURNAMENT_BEFORE_START": {
if (parentRouteData.hasStarted) {
if (tournament.hasStarted) {
return false;
}
break;
@ -395,7 +401,7 @@ function TeamActions() {
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(Number(e.target.value))}
>
{data.teams
{tournament.ctx.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((team) => (
@ -418,7 +424,7 @@ function TeamActions() {
<select id="memberId" name="memberId">
{selectedTeam.members.map((member) => (
<option key={member.userId} value={member.userId}>
{discordFullName(member)}
{member.discordName}
</option>
))}
</select>
@ -430,6 +436,18 @@ function TeamActions() {
<UserSearch inputName="userId" id="user" />
</div>
) : null}
{selectedAction.inputs.includes("BRACKET") ? (
<div>
<label htmlFor="bracket">Bracket</label>
<select id="bracket" name="bracketIdx">
{tournament.brackets.map((bracket, bracketIdx) => (
<option key={bracket.name} value={bracketIdx}>
{bracket.name}
</option>
))}
</select>
</div>
) : null}
<SubmitButton
_action={selectedAction.type}
state={fetcher.state}
@ -444,12 +462,12 @@ function TeamActions() {
}
function Staff() {
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<div className="stack lg">
{/* Key so inputs are cleared after staff is added */}
<StaffAdder key={data.tournament.staff.length} />
<StaffAdder key={tournament.ctx.staff.length} />
<StaffList />
</div>
);
@ -458,7 +476,7 @@ function Staff() {
function CastTwitchAccounts() {
const id = React.useId();
const fetcher = useFetcher();
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<fetcher.Form method="post" className="stack sm">
@ -469,7 +487,7 @@ function CastTwitchAccounts() {
id={id}
placeholder="dappleproductions"
name="castTwitchAccounts"
defaultValue={data.tournament.castTwitchAccounts?.join(",")}
defaultValue={tournament.ctx.castTwitchAccounts?.join(",")}
/>
</div>
<SubmitButton
@ -492,7 +510,7 @@ function CastTwitchAccounts() {
function StaffAdder() {
const fetcher = useFetcher();
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<fetcher.Form method="post" className="stack sm">
@ -504,8 +522,8 @@ function StaffAdder() {
id="staff-user"
userIdsToOmit={
new Set([
data.tournament.author.id,
...data.tournament.staff.map((s) => s.id),
tournament.ctx.author.id,
...tournament.ctx.staff.map((s) => s.id),
])
}
/>
@ -536,11 +554,11 @@ function StaffAdder() {
function StaffList() {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<div className="stack md">
{data.tournament.staff.map((staff) => (
{tournament.ctx.staff.map((staff) => (
<div
key={staff.id}
className="stack horizontal sm items-center"
@ -563,7 +581,7 @@ function StaffList() {
function RemoveStaffButton({
staff,
}: {
staff: TournamentLoaderData["tournament"]["staff"][number];
staff: TournamentData["ctx"]["staff"][number];
}) {
const { t } = useTranslation(["tournament"]);
@ -590,10 +608,10 @@ function RemoveStaffButton({
}
function EnableMapList() {
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const submit = useSubmit();
const [eventStarted, setEventStarted] = React.useState(
Boolean(data.tournament.showMapListGenerator),
Boolean(tournament.ctx.showMapListGenerator),
);
function handleToggle(toggled: boolean) {
setEventStarted(toggled);
@ -614,19 +632,17 @@ function EnableMapList() {
}
function DownloadParticipants() {
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
function allParticipantsContent() {
return data.teams
return tournament.ctx.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((team) => {
const owner = team.members.find((user) => user.isOwner);
invariant(owner);
return `${team.name} - ${discordFullName(owner)} - <@${
owner.discordId
}>`;
return `${team.name} - ${owner.discordName} - <@${owner.discordId}>`;
})
.join("\n");
}
@ -636,17 +652,15 @@ function DownloadParticipants() {
return (
header +
data.teams
tournament.ctx.teams
.slice()
.sort((a, b) => a.createdAt - b.createdAt)
.filter((team) => team.checkedInAt)
.filter((team) => team.checkIns.length > 0)
.map((team, i) => {
return `${i + 1}) ${team.name} - ${databaseTimestampToDate(
team.createdAt,
).toISOString()} - ${team.members
.map(
(member) => `${discordFullName(member)} - <@${member.discordId}>`,
)
.map((member) => `${member.discordName} - <@${member.discordId}>`)
.join(" / ")}`;
})
.join("\n")
@ -654,25 +668,23 @@ function DownloadParticipants() {
}
function notCheckedInParticipantsContent() {
return data.teams
return tournament.ctx.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.filter((team) => !team.checkedInAt)
.filter((team) => team.checkIns.length === 0)
.map((team) => {
return `${team.name} - ${team.members
.map(
(member) => `${discordFullName(member)} - <@${member.discordId}>`,
)
.map((member) => `${member.discordName} - <@${member.discordId}>`)
.join(" / ")}`;
})
.join("\n");
}
function simpleListInSeededOrder() {
return data.teams
return tournament.ctx.teams
.slice()
.sort((a, b) => (a.seed ?? Infinity) - (b.seed ?? Infinity))
.filter((team) => team.checkedInAt)
.filter((team) => team.checkIns.length > 0)
.map((team) => team.name)
.join("\n");
}

View File

@ -10,5 +10,5 @@ export const loader = ({ params }: LoaderFunctionArgs) => {
throw redirect(tournamentRegisterPage(eventId));
}
throw redirect(tournamentBracketsPage(eventId));
throw redirect(tournamentBracketsPage({ tournamentId: eventId }));
};

View File

@ -1,6 +1,8 @@
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData, useOutletContext } from "@remix-run/react";
import { Form, useLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { SubmitButton } from "~/components/SubmitButton";
import { INVITE_CODE_LENGTH } from "~/constants";
@ -9,22 +11,19 @@ import { requireUserId } from "~/features/auth/core/user.server";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import { tournamentPage } from "~/utils/urls";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findByInviteCode } from "../queries/findTeamByInviteCode.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { giveTrust } from "../queries/giveTrust.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { TOURNAMENT } from "../tournament-constants";
import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import React from "react";
import { discordFullName } from "~/utils/strings";
import { joinSchema } from "../tournament-schemas.server";
import { giveTrust } from "../queries/giveTrust.server";
import {
tournamentIdFromParams,
tournamentTeamMaxSize,
} from "../tournament-utils";
import { useTranslation } from "react-i18next";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { useTournament } from "./to.$id";
export const action: ActionFunction = async ({ request, params }) => {
const tournamentId = tournamentIdFromParams(params);
@ -113,15 +112,13 @@ export default function JoinTeamPage() {
const { t } = useTranslation(["tournament", "common"]);
const id = React.useId();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const teamToJoin = parentRouteData.teams.find(
(team) => team.id === data.teamId,
);
const teamToJoin = data.teamId ? tournament.teamById(data.teamId) : undefined;
const captain = teamToJoin?.members.find((member) => member.isOwner);
const validationStatus = validateCanJoin({
tournament: parentRouteData.tournament,
tournament: tournament.ctx,
inviteCode: data.inviteCode,
teamToJoin,
userId: user?.id,
@ -142,7 +139,7 @@ export default function JoinTeamPage() {
return t("tournament:join.VALID", {
teamName: teamToJoin.name,
eventName: parentRouteData.tournament.name,
eventName: tournament.ctx.name,
});
}
default: {
@ -160,7 +157,7 @@ export default function JoinTeamPage() {
<input id={id} type="checkbox" name="trust" />{" "}
<label htmlFor={id} className="mb-0">
{t("tournament:join.giveTrust", {
name: captain ? discordFullName(captain) : "",
name: captain ? captain.discordName : "",
})}
</label>
</div>
@ -181,7 +178,7 @@ function validateCanJoin({
tournament,
}: {
inviteCode?: string | null;
teamToJoin?: TournamentLoaderTeam;
teamToJoin?: { members: { userId: number }[] };
userId?: number;
tournamentHasStarted: boolean;
tournament: { name: string };

View File

@ -1,39 +1,30 @@
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import {
useActionData,
useLoaderData,
useOutletContext,
} from "@remix-run/react";
import { useActionData, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import type { MapPoolMap } from "~/db/types";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core";
import { getUserId } from "~/features/auth/core/user.server";
import { createTournamentMapList } from "~/modules/tournament-map-list-generator";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type {
TournamentMapListMap,
BracketType,
TournamentMapListMap,
TournamentMaplistInput,
TournamentMaplistSource,
} from "~/modules/tournament-map-list-generator";
import { isTournamentAdmin } from "~/permissions";
import { createTournamentMapList } from "~/modules/tournament-map-list-generator";
import mapsStyles from "~/styles/maps.css";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
import { type SendouRouteHandle } from "~/utils/remix";
import { tournamentPage } from "~/utils/urls";
import { findMapPoolsByTournamentId } from "../queries/findMapPoolsByTournamentId.server";
import { TOURNAMENT } from "../tournament-constants";
import {
modesIncluded,
resolveOwnedTeam,
tournamentIdFromParams,
} from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import * as TournamentRepository from "../TournamentRepository.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournament } from "./to.$id";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: mapsStyles }];
@ -45,18 +36,16 @@ export const handle: SendouRouteHandle = {
type TeamInState = {
id: number;
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[];
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[] | null;
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const user = await getUserId(request);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const tournament = await tournamentFromDB({ tournamentId, user });
const mapListGeneratorAvailable =
isTournamentAdmin({ user, tournament }) || tournament.showMapListGenerator;
tournament.isAdmin(user) || tournament.ctx.showMapListGenerator;
if (!mapListGeneratorAvailable) {
throw redirect(tournamentPage(tournamentId));
@ -72,7 +61,7 @@ export default function TournamentMapsPage() {
const { t } = useTranslation(["tournament"]);
const actionData = useActionData<{ failed?: boolean }>();
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const [bestOf, setBestOf] = useSearchParamState<
(typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]
@ -84,15 +73,14 @@ export default function TournamentMapsPage() {
const [teamOneId, setTeamOneId] = useSearchParamState({
name: "team-one",
defaultValue:
resolveOwnedTeam({ teams: parentRouteData.teams, userId: user?.id })
?.id ?? parentRouteData.teams[0]?.id,
revive: reviveTeam(parentRouteData.teams.map((t) => t.id)),
tournament.ownedTeamByUser(user)?.id ?? tournament.ctx.teams[0]?.id,
revive: reviveTeam(tournament.ctx.teams.map((t) => t.id)),
});
const [teamTwoId, setTeamTwoId] = useSearchParamState({
name: "team-two",
defaultValue: parentRouteData.teams[1]?.id,
defaultValue: tournament.ctx.teams[1]?.id,
revive: reviveTeam(
parentRouteData.teams.map((t) => t.id),
tournament.ctx.teams.map((t) => t.id),
teamOneId,
),
});
@ -107,11 +95,11 @@ export default function TournamentMapsPage() {
revive: reviveBracketType,
});
const teamOne = parentRouteData.teams.find((t) => t.id === teamOneId) ?? {
const teamOne = tournament.ctx.teams.find((t) => t.id === teamOneId) ?? {
id: -1,
mapPool: [],
};
const teamTwo = parentRouteData.teams.find((t) => t.id === teamTwoId) ?? {
const teamTwo = tournament.ctx.teams.find((t) => t.id === teamTwoId) ?? {
id: -1,
mapPool: [],
};
@ -160,7 +148,7 @@ export default function TournamentMapsPage() {
]}
bestOf={bestOf}
seed={`${bracketType}-${roundNumber}`}
modesIncluded={modesIncluded(parentRouteData.tournament)}
modesIncluded={tournament.modesIncluded}
/>
</div>
);
@ -249,7 +237,7 @@ function TeamsSelect({
setTeam: (newTeamId: number) => void;
}) {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<div className="tournament__select-container">
@ -265,7 +253,7 @@ function TeamsSelect({
}}
>
<option value={-1}>({t("tournament:team.unlisted")})</option>
{data.teams
{tournament.ctx.teams
.filter((t) => t.id !== otherTeam.id)
.map((team) => (
<option key={team.id} value={team.id}>
@ -309,14 +297,14 @@ function BestOfRadios({
function MapList(props: Omit<TournamentMaplistInput, "tiebreakerMaps">) {
const { t } = useTranslation(["game-misc"]);
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
let mapList: Array<TournamentMapListMap>;
try {
mapList = createTournamentMapList({
...props,
tiebreakerMaps: new MapPool(data.tournament.tieBreakerMapPool),
tiebreakerMaps: new MapPool(tournament.ctx.tieBreakerMapPool),
});
} catch (e) {
console.error(
@ -336,7 +324,7 @@ function MapList(props: Omit<TournamentMaplistInput, "tiebreakerMaps">) {
maps: new MapPool([]),
},
],
tiebreakerMaps: new MapPool(data.tournament.tieBreakerMapPool),
tiebreakerMaps: new MapPool(tournament.ctx.tieBreakerMapPool),
});
}

View File

@ -3,33 +3,38 @@ import {
type ActionFunction,
type LoaderFunctionArgs,
} from "@remix-run/node";
import {
Link,
useFetcher,
useLoaderData,
useOutletContext,
} from "@remix-run/react";
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import invariant from "tiny-invariant";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, ModeImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Popover } from "~/components/Popover";
import { SubmitButton } from "~/components/SubmitButton";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClockIcon } from "~/components/icons/Clock";
import { CrossIcon } from "~/components/icons/Cross";
import { UserIcon } from "~/components/icons/User";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core";
import { getUser, requireUser } from "~/features/auth/core/user.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
import {
tournamentFromDB,
type TournamentDataTeam,
} from "~/features/tournament-bracket/core/Tournament.server";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import type {
ModeShort,
RankedModeShort,
@ -37,7 +42,6 @@ import type {
} from "~/modules/in-game-lists";
import { stageIds } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { databaseTimestampToDate } from "~/utils/dates";
import {
notFoundIfFalsy,
parseRequestFormData,
@ -46,7 +50,6 @@ import {
} from "~/utils/remix";
import { booleanToInt } from "~/utils/sql";
import { discordFullName } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
import {
CALENDAR_PAGE,
@ -60,6 +63,7 @@ import {
} from "~/utils/urls";
import { checkIn } from "../queries/checkIn.server";
import { createTeam } from "../queries/createTeam.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTeam } from "../queries/findOwnTeam.server";
@ -75,25 +79,10 @@ import { useSelectCounterpickMapPoolState } from "../tournament-hooks";
import { registerSchema } from "../tournament-schemas.server";
import {
HACKY_isInviteOnlyEvent,
HACKY_maxRosterSizeBeforeStart,
HACKY_resolveCheckInTime,
HACKY_resolvePicture,
HACKY_subsFeatureEnabled,
checkInHasEnded,
isOneModeTournamentOf,
modesIncluded,
resolveOwnedTeam,
tournamentIdFromParams,
validateCanCheckIn,
} from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { deleteTeam } from "../queries/deleteTeam.server";
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
import { Popover } from "~/components/Popover";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { useTournament } from "./to.$id";
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
@ -108,6 +97,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestFormData({ request, schema: registerSchema });
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
const hasStarted = hasTournamentStarted(tournamentId);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
@ -176,13 +166,15 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "CHECK_IN": {
validate(tournament.regularCheckInIsOpen, "Check in is not open");
validate(ownTeam);
validate(!ownTeam.checkedInAt, "You have already checked in");
validateCanCheckIn({
event,
team: ownTeam,
mapPool: findMapPoolByTeamId(ownTeam.id),
});
validate(
tournament.checkInConditionsFulfilled({
tournamentTeamId: ownTeam.id,
mapPool: findMapPoolByTeamId(ownTeam.id),
}),
);
checkIn(ownTeam.id);
break;
@ -232,7 +224,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const hasStarted = hasTournamentStarted(eventId);
if (hasStarted) {
throw redirect(tournamentBracketsPage(eventId));
throw redirect(tournamentBracketsPage({ tournamentId: eventId }));
}
const user = await getUser(request);
@ -254,39 +246,35 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function TournamentRegisterPage() {
const user = useUser();
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const isRegularMemberOfATeam = Boolean(
parentRouteData.teamMemberOfName && !parentRouteData.ownTeam,
);
const isRegularMemberOfATeam =
tournament.teamMemberOfByUser(user) && !tournament.ownedTeamByUser(user);
return (
<div className="stack lg">
<div className="tournament__logo-container">
<img
src={HACKY_resolvePicture(parentRouteData.tournament)}
src={tournament.logoSrc}
alt=""
className="tournament__logo"
width={124}
height={124}
/>
<div>
<div className="tournament__title">
{parentRouteData.tournament.name}
</div>
<div className="tournament__title">{tournament.ctx.name}</div>
<div className="tournament__by">
<div className="stack horizontal xs items-center">
<UserIcon className="tournament__info__icon" />{" "}
{parentRouteData.tournament.author.discordName}
{tournament.ctx.author.discordName}
</div>
<div className="stack horizontal xs items-center">
<ClockIcon className="tournament__info__icon" />{" "}
{isMounted
? databaseTimestampToDate(
parentRouteData.tournament.startTime,
).toLocaleString(i18n.language, {
? tournament.ctx.startTime.toLocaleString(i18n.language, {
timeZoneName: "short",
minute: "numeric",
hour: "numeric",
@ -296,7 +284,7 @@ export default function TournamentRegisterPage() {
: null}
</div>
<div className="stack horizontal sm mt-1">
{modesIncluded(parentRouteData.tournament).map((mode) => (
{tournament.modesIncluded.map((mode) => (
<div key={mode} className="tournament___info__mode-container">
<ModeImage mode={mode} size={18} />
</div>
@ -305,16 +293,15 @@ export default function TournamentRegisterPage() {
</div>
</div>
</div>
<div>{parentRouteData.tournament.description}</div>
<div>{tournament.ctx.description}</div>
{isRegularMemberOfATeam ? (
<Alert>{t("tournament:pre.inATeam")}</Alert>
) : (
<RegistrationForms ownTeam={parentRouteData?.ownTeam} />
<RegistrationForms />
)}
{!parentRouteData.teamMemberOfName &&
HACKY_subsFeatureEnabled(parentRouteData.tournament) ? (
{!tournament.teamMemberOfByUser(user) && tournament.subsFeatureEnabled ? (
<Link
to={tournamentSubsPage(parentRouteData.tournament.id)}
to={tournamentSubsPage(tournament.ctx.id)}
className="text-xs text-center"
>
{t("tournament:pre.sub.prompt")}
@ -336,57 +323,51 @@ function PleaseLogIn() {
);
}
function RegistrationForms({
ownTeam,
}: {
ownTeam?: TournamentLoaderData["ownTeam"];
}) {
function RegistrationForms() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
if (!user && !HACKY_isInviteOnlyEvent(parentRouteData.tournament)) {
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
if (!user && tournament.hasOpenRegistration) {
return <PleaseLogIn />;
}
const ownTeamFromList = resolveOwnedTeam({
teams: parentRouteData.teams,
userId: user?.id,
});
const showRegistrationProgress = () => {
if (ownTeam) return true;
return !HACKY_isInviteOnlyEvent(parentRouteData.tournament);
return tournament.hasOpenRegistration;
};
const showRegisterNewTeam = () => {
if (ownTeam) return true;
if (HACKY_isInviteOnlyEvent(parentRouteData.tournament)) return false;
if (!tournament.hasOpenRegistration) return false;
return !checkInHasEnded(parentRouteData.tournament);
return !tournament.regularCheckInHasEnded;
};
return (
<div className="stack lg">
{showRegistrationProgress() ? (
<RegistrationProgress
checkedIn={Boolean(ownTeam?.checkedInAt)}
checkedIn={ownTeamCheckedIn}
name={ownTeam?.name}
mapPool={data?.mapPool}
members={ownTeamFromList?.members}
members={ownTeam?.members}
/>
) : null}
{showRegisterNewTeam() ? (
<TeamInfo
name={ownTeam?.name}
prefersNotToHost={ownTeamFromList?.prefersNotToHost}
canUnregister={Boolean(ownTeam && !ownTeam.checkedInAt)}
prefersNotToHost={ownTeam?.prefersNotToHost}
canUnregister={Boolean(ownTeam && !ownTeamCheckedIn)}
/>
) : null}
{ownTeam ? (
<>
<FillRoster ownTeam={ownTeam} />
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
<CounterPickMapPoolPicker />
</>
) : null}
@ -406,7 +387,7 @@ function RegistrationProgress({
mapPool?: unknown[];
}) {
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const steps = [
{
@ -428,22 +409,6 @@ function RegistrationProgress({
},
];
const checkInStartsDate = HACKY_resolveCheckInTime(
parentRouteData.tournament,
);
const checkInEndsDate = databaseTimestampToDate(
parentRouteData.tournament.startTime,
);
const now = new Date();
const checkInIsOpen =
now.getTime() > checkInStartsDate.getTime() &&
now.getTime() < checkInEndsDate.getTime();
const checkInIsOver =
now.getTime() > checkInEndsDate.getTime() &&
now.getTime() > checkInStartsDate.getTime();
return (
<div>
<h3 className="tournament__section-header text-center">
@ -472,9 +437,15 @@ function RegistrationProgress({
</div>
<CheckIn
canCheckIn={steps.filter((step) => !step.completed).length === 1}
status={checkInIsOpen ? "OPEN" : checkInIsOver ? "OVER" : "UPCOMING"}
startDate={checkInStartsDate}
endDate={checkInEndsDate}
status={
tournament.regularCheckInIsOpen
? "OPEN"
: tournament.regularCheckInHasEnded
? "OVER"
: "UPCOMING"
}
startDate={tournament.regularCheckInStartsAt}
endDate={tournament.regularCheckInEndsAt}
checkedIn={checkedIn}
/>
</section>
@ -654,25 +625,23 @@ function TeamInfo({
function FillRoster({
ownTeam,
ownTeamCheckedIn,
}: {
ownTeam: NonNullable<TournamentLoaderData["ownTeam"]>;
ownTeam: TournamentDataTeam;
ownTeamCheckedIn: boolean;
}) {
const data = useLoaderData<typeof loader>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const [, copyToClipboard] = useCopyToClipboard();
const { t } = useTranslation(["common", "tournament"]);
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
eventId: parentRouteData.tournament.id,
inviteCode: ownTeam.inviteCode,
eventId: tournament.ctx.id,
inviteCode: ownTeam.inviteCode!,
})}`;
const { members: ownTeamMembers } =
resolveOwnedTeam({
teams: parentRouteData.teams,
userId: user?.id,
}) ?? {};
const { members: ownTeamMembers } = tournament.ownedTeamByUser(user) ?? {};
invariant(ownTeamMembers, "own team members should exist");
const missingMembers = Math.max(
@ -681,28 +650,24 @@ function FillRoster({
);
const optionalMembers = Math.max(
HACKY_maxRosterSizeBeforeStart(parentRouteData.tournament) -
ownTeamMembers.length -
missingMembers,
tournament.maxTeamMemberCount - ownTeamMembers.length - missingMembers,
0,
);
const showDeleteMemberSection =
(!ownTeam.checkedInAt && ownTeamMembers.length > 1) ||
(ownTeam.checkedInAt &&
(!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
(ownTeamCheckedIn &&
ownTeamMembers.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL);
const playersAvailableToDirectlyAdd = (() => {
return data!.trustedPlayers.filter((user) => {
return parentRouteData.teams.every((team) =>
return tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== user.id),
);
});
})();
const teamIsFull =
ownTeamMembers.length >=
HACKY_maxRosterSizeBeforeStart(parentRouteData.tournament);
const teamIsFull = ownTeamMembers.length >= tournament.maxTeamMemberCount;
return (
<div>
@ -770,7 +735,7 @@ function FillRoster({
<div className="tournament__section__warning">
{t("tournament:pre.roster.footer", {
atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
maxCount: HACKY_maxRosterSizeBeforeStart(parentRouteData.tournament),
maxCount: tournament.maxTeamMemberCount,
})}
</div>
</div>
@ -809,11 +774,7 @@ function DirectlyAddPlayerSelect({ players }: { players: TrustedPlayer[] }) {
);
}
function DeleteMember({
members,
}: {
members: Unpacked<TournamentLoaderData["teams"]>["members"];
}) {
function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) {
const { t } = useTranslation(["tournament", "common"]);
const id = React.useId();
const fetcher = useFetcher();
@ -840,7 +801,7 @@ function DeleteMember({
.filter((member) => !member.isOwner)
.map((member) => (
<option key={member.userId} value={member.userId}>
{discordFullName(member)}
{member.discordName}
</option>
))}
</select>
@ -860,7 +821,7 @@ function DeleteMember({
// TODO: useBlocker to prevent leaving page if made changes without saving
function CounterPickMapPoolPicker() {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const fetcher = useFetcher();
const { counterpickMaps, handleCounterpickMapPoolSelect } =
@ -879,6 +840,9 @@ function CounterPickMapPoolPicker() {
}),
);
const isOneModeTournamentOf =
tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null;
return (
<div>
<h3 className="tournament__section-header">
@ -897,14 +861,12 @@ function CounterPickMapPoolPicker() {
{rankedModesShort
.filter(
(mode) =>
!isOneModeTournamentOf(parentRouteData.tournament) ||
isOneModeTournamentOf(parentRouteData.tournament) === mode,
!isOneModeTournamentOf || isOneModeTournamentOf === mode,
)
.map((mode) => {
const tiebreakerStageId =
parentRouteData.tournament.tieBreakerMapPool.find(
(stage) => stage.mode === mode,
)?.stageId;
const tiebreakerStageId = tournament.ctx.tieBreakerMapPool.find(
(stage) => stage.mode === mode,
)?.stageId;
return (
<div key={mode} className="stack md">
@ -927,7 +889,7 @@ function CounterPickMapPoolPicker() {
) : null}
</div>
{new Array(
isOneModeTournamentOf(parentRouteData.tournament)
isOneModeTournamentOf
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE,
)
@ -971,7 +933,7 @@ function CounterPickMapPoolPicker() {
})}
{validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf(parentRouteData.tournament),
isOneModeTournamentOf,
) === "VALID" ? (
<SubmitButton
_action="UPDATE_MAP_POOL"
@ -985,7 +947,7 @@ function CounterPickMapPoolPicker() {
<MapPoolValidationStatusMessage
status={validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf(parentRouteData.tournament),
isOneModeTournamentOf,
)}
/>
)}

View File

@ -13,38 +13,38 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunction } from "@remix-run/node";
import {
Link,
useFetcher,
useLoaderData,
useMatches,
useNavigation,
useOutletContext,
} from "@remix-run/react";
import clsx from "clsx";
import clone from "just-clone";
import * as React from "react";
import invariant from "tiny-invariant";
import { Alert } from "~/components/Alert";
import { Button } from "~/components/Button";
import { Catcher } from "~/components/Catcher";
import { Draggable } from "~/components/Draggable";
import { useTimeoutState } from "~/hooks/useTimeoutState";
import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id";
import { Image, TierImage } from "~/components/Image";
import { navIconUrl, tournamentBracketsPage } from "~/utils/urls";
import { requireUser } from "~/features/auth/core";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { seedsActionSchema } from "../tournament-schemas.server";
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
import { tournamentIdFromParams } from "../tournament-utils";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { isTournamentOrganizer } from "~/permissions";
import { SubmitButton } from "~/components/SubmitButton";
import clone from "just-clone";
import * as TournamentRepository from "../TournamentRepository.server";
import { requireUser } from "~/features/auth/core";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
import { currentOrPreviousSeason } from "~/features/mmr/season";
import {
tournamentFromDB,
type TournamentDataTeam,
} from "~/features/tournament-bracket/core/Tournament.server";
import { useTimeoutState } from "~/hooks/useTimeoutState";
import { parseRequestFormData, validate } from "~/utils/remix";
import { navIconUrl, tournamentBracketsPage, userPage } from "~/utils/urls";
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
import { seedsActionSchema } from "../tournament-schemas.server";
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournament } from "./to.$id";
export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestFormData({
@ -53,14 +53,10 @@ export const action: ActionFunction = async ({ request, params }) => {
});
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const tournament = await tournamentFromDB({ tournamentId, user });
const hasStarted = hasTournamentStarted(tournamentId);
validate(isTournamentOrganizer({ user, tournament }));
validate(!hasStarted, "Tournament has started");
validate(tournament.isOrganizer(user));
validate(!tournament.hasStarted, "Tournament has started");
updateTeamSeeds({ tournamentId, teamIds: data.seeds });
@ -70,13 +66,10 @@ export const action: ActionFunction = async ({ request, params }) => {
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const hasStarted = hasTournamentStarted(tournamentId);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const tournament = await tournamentFromDB({ tournamentId, user });
if (!isTournamentOrganizer({ user, tournament }) || hasStarted) {
throw redirect(tournamentBracketsPage(tournamentId));
if (!tournament.isOrganizer(user) || tournament.hasStarted) {
throw redirect(tournamentBracketsPage({ tournamentId }));
}
const powers = async () => {
@ -98,24 +91,30 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export default function TournamentSeedsPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
const { teams } = parentRoute.data as TournamentLoaderData;
const tournament = useTournament();
const navigation = useNavigation();
const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id));
const [activeTeam, setActiveTeam] =
React.useState<TournamentLoaderTeam | null>(null);
const [teamOrder, setTeamOrder] = React.useState(
tournament.ctx.teams.map((t) => t.id),
);
const [activeTeam, setActiveTeam] = React.useState<TournamentDataTeam | null>(
null,
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const teamsSorted = teams.sort(
const teamsSorted = tournament.ctx.teams.sort(
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
);
const rankTeam = (team: TournamentLoaderTeam) => {
const rankTeam = (team: TournamentDataTeam) => {
const powers = team.members
.map((m) => data.powers[m.userId]?.power)
.filter(Boolean);
@ -133,7 +132,7 @@ export default function TournamentSeedsPage() {
type="button"
onClick={() => {
setTeamOrder(
clone(teams)
clone(tournament.ctx.teams)
.sort((a, b) => rankTeam(b) - rankTeam(a))
.map((t) => t.id),
);
@ -212,7 +211,7 @@ export default function TournamentSeedsPage() {
}
function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const [teamOrderInDb, setTeamOrderInDb] = React.useState(teamOrder);
const [showSuccess, setShowSuccess] = useTimeoutState(false);
const fetcher = useFetcher();
@ -231,7 +230,7 @@ function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
return (
<fetcher.Form method="post" className="tournament__seeds__form">
<input type="hidden" name="tournamentId" value={data.tournament.id} />
<input type="hidden" name="tournamentId" value={tournament.ctx.id} />
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
<Alert
variation={
@ -265,7 +264,7 @@ function RowContents({
team,
seed,
}: {
team: TournamentLoaderTeam;
team: TournamentDataTeam;
seed?: number;
}) {
const data = useLoaderData<typeof loader>();
@ -273,7 +272,9 @@ function RowContents({
return (
<>
<div>{seed}</div>
<div className="tournament__seeds__team-name">{team.name}</div>
<div className="tournament__seeds__team-name">
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
</div>
<div className="stack horizontal sm">
{team.members.map((member) => {
const { power, tier } = data.powers[member.userId] ?? {};
@ -282,9 +283,13 @@ function RowContents({
return (
<div key={member.userId} className="tournament__seeds__team-member">
<div className="tournament__seeds__team-member__name">
<Link
to={userPage(member)}
target="_blank"
className="tournament__seeds__team-member__name"
>
{member.discordName}
</div>
</Link>
{member.plusTier ? (
<div
className={clsx("stack horizontal items-center xxs", {

View File

@ -1,40 +1,37 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useOutletContext } from "@remix-run/react";
import { streamsByTournamentId } from "../core/streams.server";
import { tournamentIdFromParams } from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import { useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Redirect } from "~/components/Redirect";
import { tournamentRegisterPage, twitchUrl } from "~/utils/urls";
import { UserIcon } from "~/components/icons/User";
import { useTranslation } from "react-i18next";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import { tournamentRegisterPage, twitchUrl } from "~/utils/urls";
import * as TournamentRepository from "../TournamentRepository.server";
import { notFoundIfFalsy } from "~/utils/remix";
import { streamsByTournamentId } from "../core/streams.server";
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournament } from "./to.$id";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
return {
streams: await streamsByTournamentId({
tournamentId,
castTwitchAccounts: tournament.castTwitchAccounts,
castTwitchAccounts:
await TournamentRepository.findCastTwitchAccountsByTournamentId(
tournamentId,
),
}),
};
};
export default function TournamentStreamsPage() {
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
if (!parentRouteData.hasStarted || parentRouteData.hasFinalized) {
return (
<Redirect to={tournamentRegisterPage(parentRouteData.tournament.id)} />
);
if (!tournament.hasStarted || tournament.everyBracketOver) {
return <Redirect to={tournamentRegisterPage(tournament.ctx.id)} />;
}
if (data.streams.length === 0) {
@ -49,7 +46,7 @@ export default function TournamentStreamsPage() {
return (
<div className="stack horizontal lg flex-wrap justify-center">
{data.streams.map((stream) => {
const team = parentRouteData.teams.find((team) =>
const team = tournament.ctx.teams.find((team) =>
team.members.some((m) => m.userId === stream.userId),
);
const user = team?.members.find((m) => m.userId === stream.userId);

View File

@ -1,108 +1,75 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import { Placement } from "~/components/Placement";
import {
everyMatchIsOver,
finalStandingOfTeam,
getTournamentManager,
findMapPoolByTeamId,
} from "~/features/tournament-bracket";
import { TeamWithRoster } from "../components/TeamWithRoster";
import {
type PlayedSet,
tournamentTeamSets,
winCounts,
} from "../core/sets.server";
import {
tournamentIdFromParams,
tournamentRoundI18nKey,
tournamentTeamIdFromParams,
} from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import { ModeImage, StageImage } from "~/components/Image";
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { ModeImage, StageImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { Popover } from "~/components/Popover";
import { Redirect } from "~/components/Redirect";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import {
tournamentMatchPage,
tournamentPage,
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import { useTranslation } from "react-i18next";
import { Redirect } from "~/components/Redirect";
import { Popover } from "~/components/Popover";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { isTournamentOrganizer } from "~/permissions";
import { getUserId } from "~/features/auth/core/user.server";
import { notFoundIfFalsy } from "~/utils/remix";
import * as TournamentRepository from "../TournamentRepository.server";
import { TeamWithRoster } from "../components/TeamWithRoster";
import {
tournamentTeamSets,
winCounts,
type PlayedSet,
} from "../core/sets.server";
import {
tournamentIdFromParams,
tournamentRoundI18nKey,
tournamentTeamIdFromParams,
} from "../tournament-utils";
import { useTournament } from "./to.$id";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
export const loader = ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournamentTeamId = tournamentTeamIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const manager = getTournamentManager("SQL");
const bracket = manager.get.tournamentData(tournamentId);
const stage = bracket.stage[0];
// TODO: handle placement when multiple stages
const _everyMatchIsOver = everyMatchIsOver(bracket);
const standing = _everyMatchIsOver
? finalStandingOfTeam({
manager,
tournamentId,
tournamentTeamId,
stageId: stage.id,
})
: null;
const sets = tournamentTeamSets({ tournamentTeamId, tournamentId });
const revealMapPool =
hasTournamentStarted(tournamentId) ||
isTournamentOrganizer({ user, tournament });
return {
tournamentTeamId,
mapPool: revealMapPool ? findMapPoolByTeamId(tournamentTeamId) : null,
placement: standing?.placement,
sets,
// TODO: could be inferred from tournament data
winCounts: winCounts(sets),
playersThatPlayed: standing?.players.map((p) => p.id),
};
};
// TODO: could cache this after tournament is finalized
export default function TournamentTeamPage() {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamIndex = parentRouteData.teams.findIndex(
const tournament = useTournament();
const teamIndex = tournament.ctx.teams.findIndex(
(t) => t.id === data.tournamentTeamId,
);
const team = parentRouteData.teams[teamIndex];
const team = tournament.teamById(data.tournamentTeamId);
if (!team) {
return <Redirect to={tournamentPage(parentRouteData.tournament.id)} />;
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
}
return (
<div className="stack lg">
<TeamWithRoster
team={team}
mapPool={data.mapPool}
activePlayers={data.playersThatPlayed}
mapPool={team.mapPool}
activePlayers={
data.sets.length > 0
? tournament
.participatedPlayersByTeamId(team.id)
.map((p) => p.userId)
: undefined
}
/>
{data.winCounts.sets.total > 0 ? (
<StatSquares
seed={teamIndex + 1}
teamsCount={parentRouteData.teams.length}
teamsCount={tournament.ctx.teams.length}
/>
) : null}
<div className="tournament__team__sets">
@ -123,6 +90,16 @@ function StatSquares({
}) {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const placement = tournament.standings.find(
(s) => s.team.id === data.tournamentTeamId,
)?.placement;
const undergroundBracket = tournament.brackets.find((b) => b.isUnderground);
const undergroundPlacement = undergroundBracket?.standings.find(
(s) => s.team.id === data.tournamentTeamId,
)?.placement;
return (
<div className="tournament__team__stats">
@ -165,26 +142,27 @@ function StatSquares({
{t("tournament:team.placement")}
</div>
<div className="tournament__team__stat__main">
{data.placement ? (
<Placement placement={data.placement} textOnly />
) : (
"-"
)}
{placement ? <Placement placement={placement} textOnly /> : "-"}
{undergroundPlacement ? (
<>
{" "}
/ <Placement placement={undergroundPlacement} textOnly />
</>
) : null}
</div>
{undergroundPlacement ? (
<div className="tournament__team__stat__sub">
{t("tournament:team.placement.footer")}
</div>
) : null}
</div>
</div>
);
}
function SetInfo({
set,
team,
}: {
set: PlayedSet;
team: FindTeamsByTournamentIdItem;
}) {
function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) {
const { t } = useTranslation(["tournament"]);
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const sourceToText = (source: TournamentMaplistSource) => {
switch (source) {
@ -212,14 +190,14 @@ function SetInfo({
<Link
to={tournamentMatchPage({
matchId: set.tournamentMatchId,
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
})}
className="tournament__team__set__round-name"
>
{t(`tournament:${tournamentRoundI18nKey(set.round)}`, {
round: set.round.round,
})}{" "}
- {t(`tournament:bracket.${set.bracket}`)}
- {set.stageName}
</Link>
</div>
<div className="overlap-divider">
@ -257,7 +235,7 @@ function SetInfo({
<Link
to={tournamentTeamPage({
tournamentTeamId: set.opponent.id,
eventId: parentRouteData.tournament.id,
eventId: tournament.ctx.id,
})}
className="tournament__team__set__opponent__team"
>

View File

@ -1,21 +1,20 @@
import { useOutletContext } from "@remix-run/react";
import { tournamentTeamPage } from "~/utils/urls";
import { TeamWithRoster } from "../components/TeamWithRoster";
import type { TournamentLoaderData } from "./to.$id";
import { useTournament } from "./to.$id";
export default function TournamentTeamsPage() {
const data = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
return (
<div className="stack lg">
{data.teams.map((team, i) => {
{tournament.ctx.teams.map((team, i) => {
return (
<TeamWithRoster
key={team.id}
team={team}
seed={i + 1}
teamPageUrl={tournamentTeamPage({
eventId: data.tournament.id,
eventId: tournament.ctx.id,
tournamentTeamId: team.id,
})}
/>

View File

@ -1,61 +1,37 @@
import type {
LinksFunction,
LoaderFunctionArgs,
SerializeFrom,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import {
Outlet,
useLoaderData,
useLocation,
type ShouldRevalidateFunction,
useOutletContext,
} from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core";
import { getUser } from "~/features/auth/core/user.server";
import { isTournamentOrganizer } from "~/permissions";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable, type Unpacked } from "~/utils/types";
import { streamsByTournamentId } from "../core/streams.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import {
HACKY_subsFeatureEnabled,
teamHasCheckedIn,
tournamentIdFromParams,
} from "../tournament-utils";
import styles from "../tournament.css";
import { findOwnTeam } from "../queries/findOwnTeam.server";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { findSubsByTournamentId } from "~/features/tournament-subs";
import hasTournamentFinalized from "../queries/hasTournamentFinalized.server";
import * as TournamentRepository from "../TournamentRepository.server";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const wasMutation = args.formMethod === "POST";
const wasOnMatchPage = args.formAction?.includes("matches");
if (wasMutation && wasOnMatchPage) {
return false;
}
const wasRevalidation = !args.formMethod;
if (wasRevalidation) {
return false;
}
return args.defaultShouldRevalidate;
};
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { streamsByTournamentId } from "../core/streams.server";
import { tournamentIdFromParams } from "../tournament-utils";
import styles from "../tournament.css";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
if (!data) return [];
return [{ title: makeTitle(data.tournament.name) }];
return [{ title: makeTitle(data.tournament.ctx.name) }];
};
export const links: LinksFunction = () => {
@ -66,29 +42,11 @@ export const handle: SendouRouteHandle = {
i18n: ["tournament", "calendar"],
};
export type TournamentLoaderTeam = Unpacked<TournamentLoaderData["teams"]>;
export type TournamentLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = await getUser(request);
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(
await TournamentRepository.findById(tournamentId),
);
const hasStarted = hasTournamentStarted(tournamentId);
let teams = findTeamsByTournamentId(tournamentId);
if (hasStarted) {
const checkedInTeams = teams.filter(teamHasCheckedIn);
// handle special case where tournament was started early
if (checkedInTeams.length > 0) {
teams = checkedInTeams;
}
}
const teamMemberOfName = teams.find((team) =>
team.members.some((member) => member.userId === user?.id),
)?.name;
const subsCount = findSubsByTournamentId({
tournamentId,
@ -115,43 +73,51 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
}
}).length;
const tournament = await tournamentData({ tournamentId, user });
return {
tournament,
ownTeam: user
? findOwnTeam({
tournamentId,
userId: user.id,
})
: null,
teamMemberOfName,
teams,
hasStarted,
hasFinalized: hasTournamentFinalized(tournamentId),
subsCount,
streamsCount: hasStarted
? (
await streamsByTournamentId({
tournamentId,
castTwitchAccounts: tournament.castTwitchAccounts,
})
).length
: 0,
streamsCount:
tournament.ctx.inProgressBrackets.length > 0
? (
await streamsByTournamentId({
tournamentId,
castTwitchAccounts: tournament.ctx.castTwitchAccounts,
})
).length
: 0,
};
};
const TournamentContext = React.createContext<Tournament>(null!);
// TODO: icons to nav could be nice
export default function TournamentLayout() {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const location = useLocation();
const tournament = React.useMemo(
() => new Tournament(data.tournament),
[data],
);
// this is nice to debug with tournament in browser console
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
// @ts-expect-error for dev purposes
window.tourney = tournament;
}, [tournament]);
}
const onBracketsPage = location.pathname.includes("brackets");
return (
<Main bigger={onBracketsPage}>
<SubNav>
{!data.hasStarted ? (
{!tournament.hasStarted ? (
<SubNavLink to="register" data-testid="register-tab">
{t("tournament:tabs.register")}
</SubNavLink>
@ -159,34 +125,38 @@ export default function TournamentLayout() {
<SubNavLink to="brackets" data-testid="brackets-tab">
{t("tournament:tabs.brackets")}
</SubNavLink>
{data.tournament.showMapListGenerator ? (
{tournament.ctx.showMapListGenerator ? (
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
) : null}
<SubNavLink to="teams" end={false}>
{t("tournament:tabs.teams", { count: data.teams.length })}
{t("tournament:tabs.teams", { count: tournament.ctx.teams.length })}
</SubNavLink>
{!data.hasFinalized && HACKY_subsFeatureEnabled(data.tournament) && (
{!tournament.everyBracketOver && tournament.subsFeatureEnabled && (
<SubNavLink to="subs" end={false}>
{t("tournament:tabs.subs", { count: data.subsCount })}
</SubNavLink>
)}
{data.hasStarted && !data.hasFinalized ? (
{tournament.hasStarted && !tournament.everyBracketOver ? (
<SubNavLink to="streams">
{t("tournament:tabs.streams", { count: data.streamsCount })}
</SubNavLink>
) : null}
{isTournamentOrganizer({ user, tournament: data.tournament }) &&
!data.hasStarted && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{isTournamentOrganizer({ user, tournament: data.tournament }) &&
!data.hasFinalized && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}
</SubNavLink>
)}
{tournament.isOrganizer(user) && !tournament.hasStarted && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}
</SubNavLink>
)}
</SubNav>
<Outlet context={data} />
<TournamentContext.Provider value={tournament}>
<Outlet context={tournament satisfies Tournament} />
</TournamentContext.Provider>
</Main>
);
}
export function useTournament() {
return useOutletContext<Tournament>();
}

View File

@ -7,4 +7,16 @@ export const TOURNAMENT = {
DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START: 6,
AVAILABLE_BEST_OF: [3, 5, 7] as const,
ENOUGH_TEAMS_TO_START: 2,
MIN_GROUP_SIZE: 3,
MAX_GROUP_SIZE: 6,
} as const;
export const BRACKET_NAMES = {
UNDERGROUND: "Underground bracket",
MAIN: "Main bracket",
GROUPS: "Group stage",
FINALS: "Final stage",
};
export const FORMATS_SHORT = ["DE", "RR_TO_SE"] as const;
export type TournamentFormatShort = (typeof FORMATS_SHORT)[number];

View File

@ -1,13 +1,12 @@
import { useLoaderData, useOutletContext } from "@remix-run/react";
import { useLoaderData } from "@remix-run/react";
import * as React from "react";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentLoaderData } from "./routes/to.$id";
import { mapPickCountPerMode } from "./tournament-utils";
import { useTournament } from "./routes/to.$id";
import type { TournamentRegisterPageLoader } from "./routes/to.$id.register";
export function useSelectCounterpickMapPoolState() {
const data = useLoaderData<TournamentRegisterPageLoader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const tournament = useTournament();
const resolveInitialMapPool = (mode: RankedModeShort) => {
const ownMapPool = data?.mapPool ?? [];
@ -16,12 +15,8 @@ export function useSelectCounterpickMapPoolState() {
.filter((pair) => pair.mode === mode)
.map((pair) => pair.stageId);
if (
filteredStages.length !== mapPickCountPerMode(parentRouteData.tournament)
) {
return new Array(mapPickCountPerMode(parentRouteData.tournament)).fill(
null,
);
if (filteredStages.length !== tournament.mapPickCountPerMode) {
return new Array(tournament.mapPickCountPerMode).fill(null);
}
return filteredStages as [StageId, StageId];
@ -44,7 +39,7 @@ export function useSelectCounterpickMapPoolState() {
(e) => {
setCounterpickMaps({
...counterpickMaps,
[mode]: new Array(mapPickCountPerMode(parentRouteData.tournament))
[mode]: new Array(tournament.mapPickCountPerMode)
.fill(null)
.map((_, i) => counterpickMaps[mode][i])
.map((stageId, j) => {

View File

@ -6,6 +6,7 @@ import {
safeJSONParse,
} from "~/utils/zod";
import { TOURNAMENT } from "./tournament-constants";
import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server";
export const registerSchema = z.union([
z.object({
@ -50,10 +51,12 @@ export const adminActionSchema = z.union([
z.object({
_action: _action("CHECK_IN"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("CHECK_OUT"),
teamId: id,
bracketIdx,
}),
z.object({
_action: _action("ADD_MEMBER"),

View File

@ -1,38 +1,11 @@
import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
import type { Tournament, User } from "~/db/types";
import type { Tournament } from "~/db/types";
import type { ModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { databaseTimestampToDate } from "~/utils/dates";
import type { FindTeamsByTournamentId } from "./queries/findTeamsByTournamentId.server";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "./routes/to.$id";
import { TOURNAMENT } from "./tournament-constants";
import { validate } from "~/utils/remix";
import type { PlayedSet } from "./core/sets.server";
import { tournamentLogoUrl } from "~/utils/urls";
export function resolveOwnedTeam({
teams,
userId,
}: {
teams: Array<TournamentLoaderTeam>;
userId?: User["id"];
}) {
if (typeof userId !== "number") return;
return teams.find((team) =>
team.members.some((member) => member.isOwner && member.userId === userId),
);
}
export function teamHasCheckedIn(
team: Pick<TournamentLoaderTeam, "checkedInAt">,
) {
return Boolean(team.checkedInAt);
}
import type { PlayedSet } from "./core/sets.server";
import { TOURNAMENT } from "./tournament-constants";
export function tournamentIdFromParams(params: Params<string>) {
const result = Number(params["id"]);
@ -106,14 +79,6 @@ export function HACKY_resolvePicture(event: { name: string }) {
return tournamentLogoUrl("default");
}
// hacky because db query not taking in account possibility of many start times
// AND always assumed check-in starts 1h before
export function HACKY_resolveCheckInTime(
event: Pick<TournamentLoaderData["tournament"], "startTime">,
) {
return databaseTimestampToDate(event.startTime - 60 * 60);
}
const HACKY_isSendouQSeasonFinale = (event: { name: string }) =>
event.name.includes("Finale");
@ -121,57 +86,12 @@ export function HACKY_isInviteOnlyEvent(event: { name: string }) {
return HACKY_isSendouQSeasonFinale(event);
}
export function HACKY_subsFeatureEnabled(
event: TournamentLoaderData["tournament"],
) {
if (HACKY_isSendouQSeasonFinale(event)) return false;
return true;
}
export function HACKY_maxRosterSizeBeforeStart(event: { name: string }) {
if (HACKY_isSendouQSeasonFinale(event)) return 5;
return TOURNAMENT.DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START;
}
export function mapPickCountPerMode(event: TournamentLoaderData["tournament"]) {
return isOneModeTournamentOf(event)
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
}
export function checkInHasStarted(
event: Pick<TournamentLoaderData["tournament"], "startTime">,
) {
return HACKY_resolveCheckInTime(event).getTime() < Date.now();
}
export function checkInHasEnded(
event: Pick<TournamentLoaderData["tournament"], "startTime">,
) {
return databaseTimestampToDate(event.startTime).getTime() < Date.now();
}
export function validateCanCheckIn({
event,
team,
mapPool,
}: {
event: Pick<TournamentLoaderData["tournament"], "startTime">;
team: FindTeamsByTournamentId[number];
mapPool: unknown[] | null;
}) {
validate(checkInHasStarted(event), "Check-in has not started yet");
validate(
team.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
"Team does not have enough members",
);
validate(mapPool && mapPool.length > 0, "Team does not have a map pool");
return true;
}
export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
if (round.round === "grand_finals") return `bracket.grand_finals` as const;
if (round.round === "bracket_reset") {

View File

@ -94,7 +94,11 @@ export function UserResultsTable({
</Link>
) : null}
{result.tournamentId ? (
<Link to={tournamentBracketsPage(result.tournamentId)}>
<Link
to={tournamentBracketsPage({
tournamentId: result.tournamentId,
})}
>
{result.eventName}
</Link>
) : null}

View File

@ -237,9 +237,8 @@ export class BaseUpdater extends BaseGetter {
};
// Only sync the child games' status with their parent's status when changing the parent match participants
// (Locked, Waiting, Ready) or when archiving the parent match.
if (match.status <= Status.Ready || match.status === Status.Archived)
updatedMatchGame.status = match.status;
// (Locked, Waiting, Ready).
if (match.status <= Status.Ready) updatedMatchGame.status = match.status;
if (
!this.storage.update(
@ -273,21 +272,8 @@ export class BaseUpdater extends BaseGetter {
);
if (previousMatches.length === 0) return;
if (match.status >= Status.Running) this.archiveMatches(previousMatches);
else this.resetMatchesStatus(previousMatches);
}
/**
* Sets the status of a list of matches to archived.
*
* @param matches The matches to update.
*/
protected archiveMatches(matches: Match[]): void {
for (const match of matches) {
if (match.status === Status.Archived) continue;
match.status = Status.Archived;
this.applyMatchUpdate(match);
if (match.status < Status.Running) {
this.resetMatchesStatus(previousMatches);
}
}
@ -327,10 +313,6 @@ export class BaseUpdater extends BaseGetter {
roundCount,
);
if (nextMatches.length === 0) {
// Archive match if it doesn't have following matches and is completed.
// When the stage is fully complete, all matches should be archived.
if (match.status === Status.Completed) this.archiveMatches([match]);
return;
}

View File

@ -734,11 +734,7 @@ export function isMatchByeCompleted(match: DeepPartial<MatchResults>): boolean {
* @param match The match to check.
*/
export function isMatchUpdateLocked(match: MatchResults): boolean {
return (
match.status === Status.Locked ||
match.status === Status.Waiting ||
match.status === Status.Archived
);
return match.status === Status.Locked || match.status === Status.Waiting;
}
/**

View File

@ -289,9 +289,6 @@ MatchUpdateDoubleElimination("should determine matches in grand final", () => {
storage.select<any>("match", 1).opponent2.id, // Winner of LB Final
);
assert.equal(storage.select<any>("match", 2).status, Status.Archived);
assert.equal(storage.select<any>("match", 4).status, Status.Archived);
assert.equal(storage.select<any>("match", 5).status, Status.Completed); // Grand final (round 1)
assert.equal(storage.select<any>("match", 6).status, Status.Ready); // Grand final (round 2)
@ -300,9 +297,6 @@ MatchUpdateDoubleElimination("should determine matches in grand final", () => {
opponent1: { score: 16, result: "win" },
opponent2: { score: 10 },
});
assert.equal(storage.select<any>("match", 5).status, Status.Archived); // Grand final (round 1)
assert.equal(storage.select<any>("match", 6).status, Status.Archived); // Grand final (round 2)
});
MatchUpdateDoubleElimination(
@ -337,99 +331,6 @@ MatchUpdateDoubleElimination(
},
);
MatchUpdateDoubleElimination("should archive previous matches", () => {
manager.create({
name: "Example",
tournamentId: 0,
type: "double_elimination",
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
settings: { grandFinal: "double" },
});
manager.update.match({
id: 0, // First match of WB round 1
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
manager.update.match({
id: 1, // Second match of WB round 1
opponent1: { score: 13 },
opponent2: { score: 16, result: "win" },
});
manager.update.match({
id: 2, // WB Final
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
});
// WB Final archived both WB round 1 matches
assert.equal(storage.select<any>("match", 0).status, Status.Archived);
assert.equal(storage.select<any>("match", 1).status, Status.Archived);
// Reset the score...
manager.update.match({
id: 2,
opponent1: { score: undefined },
opponent2: { score: undefined },
});
// ...and reset the result
manager.reset.matchResults(2); // WB Final
// Should remove the archived status
assert.equal(storage.select<any>("match", 0).status, Status.Completed);
assert.equal(storage.select<any>("match", 1).status, Status.Completed);
manager.update.match({
id: 3, // Only match of LB round 1
opponent1: { score: 12, result: "win" }, // Team 4
opponent2: { score: 8 },
});
// First round of LB archived both WB round 1 matches
assert.equal(storage.select<any>("match", 0).status, Status.Archived);
assert.equal(storage.select<any>("match", 1).status, Status.Archived);
manager.update.match({
id: 2, // WB Final
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
});
manager.update.match({
id: 4, // LB Final
opponent1: { score: 14, result: "win" }, // Team 3
opponent2: { score: 7 },
});
assert.equal(storage.select<any>("match", 2).status, Status.Archived);
assert.equal(storage.select<any>("match", 3).status, Status.Archived);
// Force status of WB Final to completed to make sure the Grand Final sets it to Archived.
storage.update("match", 2, {
...storage.select<any>("match", 2),
status: Status.Completed,
});
manager.update.match({
id: 5, // Grand Final round 1
opponent1: { score: 10 },
opponent2: { score: 16, result: "win" }, // Team 3
});
assert.equal(storage.select<any>("match", 2).status, Status.Archived);
manager.update.match({
id: 6, // Grand Final round 2
opponent1: { score: 10 },
opponent2: { score: 16, result: "win" }, // Team 3
});
assert.equal(storage.select<any>("match", 5).status, Status.Archived);
});
MatchUpdateDoubleElimination(
"should choose the correct previous and next matches based on losers ordering",
() => {
@ -486,7 +387,6 @@ MatchUpdateDoubleElimination(
manager.update.match({ id: 19, opponent1: { result: "win" } }); // LB 2.1
assert.equal(storage.select<any>("match", 8).status, Status.Completed); // WB 2.1
assert.equal(storage.select<any>("match", 11).status, Status.Archived); // WB 2.4
},
);

View File

@ -383,58 +383,14 @@ PreviousAndNextMatchUpdate(
});
assert.equal(storage.select<any>("match", 2).status, Status.Running);
assert.equal(storage.select<any>("match", 3).status, Status.Archived);
manager.update.match({
id: 2, // Final
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
});
assert.equal(storage.select<any>("match", 2).status, Status.Archived);
assert.equal(storage.select<any>("match", 3).status, Status.Archived);
},
);
PreviousAndNextMatchUpdate("should archive previous matches", () => {
manager.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
settings: { consolationFinal: true },
});
manager.update.match({
id: 0, // First match of round 1
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
manager.update.match({
id: 1, // Second match of round 1
opponent1: { score: 13 },
opponent2: { score: 16, result: "win" },
});
manager.update.match({
id: 2, // Final
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
});
assert.equal(storage.select<any>("match", 0).status, Status.Archived);
assert.equal(storage.select<any>("match", 1).status, Status.Archived);
manager.update.match({
id: 3, // Consolation final
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
});
assert.equal(storage.select<any>("match", 2).status, Status.Archived); // Final
assert.equal(storage.select<any>("match", 3).status, Status.Archived); // Consolation final
});
CreateSingleEliminationStage.run();
PreviousAndNextMatchUpdate.run();

View File

@ -396,20 +396,6 @@ LockedMatches(
},
);
LockedMatches(
"should throw when one of participants already played next match",
() => {
manager.update.match({ id: 0, opponent1: { result: "win" } });
manager.update.match({ id: 1, opponent1: { result: "win" } });
manager.update.match({ id: 8, opponent1: { result: "win" } });
assert.throws(
() => manager.update.match({ id: 0 }),
"The match is locked.",
);
},
);
const UpdateMatchGames = suite("Update match games");
UpdateMatchGames.before.each(() => {
@ -489,15 +475,12 @@ UpdateMatchGames(
});
finalMatchStatus = storage.select<any>("match", 2).status;
assert.equal(finalMatchStatus, Status.Archived);
assert.equal(finalMatchStatus, storage.select<any>("match_game", 4).status);
const semi1Status = storage.select<any>("match", 0).status;
assert.equal(semi1Status, Status.Archived);
assert.equal(semi1Status, storage.select<any>("match_game", 0).status);
const semi2Status = storage.select<any>("match", 1).status;
assert.equal(semi2Status, Status.Archived);
assert.equal(semi2Status, storage.select<any>("match_game", 2).status);
},
);
@ -571,89 +554,6 @@ UpdateMatchGames("should throw if trying to update a locked match game", () => {
);
});
UpdateMatchGames(
"should throw if trying to update a child game of a locked match",
() => {
manager.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
settings: {
seedOrdering: ["natural"],
matchesChildCount: 3, // Bo3
},
});
manager.update.matchGame({
parent_id: 0,
number: 1,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 0,
number: 2,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 1,
number: 1,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 1,
number: 2,
opponent1: { result: "win" },
});
// Starting the next match will lock previous matches and their match games.
manager.update.matchGame({
parent_id: 2,
number: 1,
opponent1: { score: 0 },
opponent2: { score: 0 },
});
assert.throws(
() =>
manager.update.matchGame({
parent_id: 0,
number: 1,
opponent1: { result: "loss" },
}),
"The match game is locked.",
);
assert.throws(
() =>
manager.update.matchGame({
parent_id: 0,
number: 2,
opponent1: { result: "loss" },
}),
"The match game is locked.",
);
assert.throws(
() =>
manager.update.matchGame({
parent_id: 1,
number: 1,
opponent1: { result: "loss" },
}),
"The match game is locked.",
);
assert.throws(
() =>
manager.update.matchGame({
parent_id: 1,
number: 2,
opponent1: { result: "loss" },
}),
"The match game is locked.",
);
},
);
UpdateMatchGames(
"should propagate the winner of the parent match in the next match",
() => {
@ -742,7 +642,6 @@ UpdateMatchGames(
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
assert.equal(storage.select<any>("match", 0).status, Status.Archived); // Completed, but single match in the stage.
manager.reset.matchGameResults(0);
assert.equal(storage.select<any>("match", 0).status, Status.Running);
@ -1304,66 +1203,6 @@ MatchGamesStatus("should set the parent match to Completed", () => {
assert.equal(storage.select<any>("match_game", 2).status, Status.Completed);
});
MatchGamesStatus(
"should archive previous matches and their games when next match is started",
() => {
manager.create({
tournamentId: 0,
name: "Example",
type: "single_elimination",
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
settings: { matchesChildCount: 3 },
});
manager.update.matchGame({
parent_id: 0,
number: 1,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 0,
number: 2,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 1,
number: 1,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 1,
number: 2,
opponent1: { result: "win" },
});
manager.update.matchGame({
parent_id: 2,
number: 1,
opponent1: { score: 0 },
opponent2: { score: 0 },
});
const firstMatchGames = storage.select<any>("match_game", {
parent_id: 0,
});
assert.equal(firstMatchGames![0].status, Status.Archived);
assert.equal(firstMatchGames![1].status, Status.Archived);
assert.equal(firstMatchGames![2].status, Status.Archived);
assert.equal(storage.select<any>("match", 0).status, Status.Archived);
const secondMatchGames = storage.select<any>("match_game", {
parent_id: 1,
});
assert.equal(secondMatchGames![0].status, Status.Archived);
assert.equal(secondMatchGames![1].status, Status.Archived);
assert.equal(secondMatchGames![2].status, Status.Archived);
assert.equal(storage.select<any>("match", 1).status, Status.Archived);
},
);
MatchGamesStatus(
"should work with unique match games when controlled via the parent",
() => {

View File

@ -22,9 +22,6 @@ export enum Status {
/** The match is completed. */
Completed = 4,
/** At least one participant completed his following match. */
Archived = 5,
}
/**
@ -43,6 +40,9 @@ export interface ParticipantResult {
/** The current score of the participant. */
score?: number;
/** How many points in total participant scored in total this set. KO = 100 points. Getting KO'd = 0 points. */
totalPoints?: number;
/** Tells what is the result of a duel for this participant. */
result?: Result;
}

View File

@ -1,6 +1,5 @@
import invariant from "tiny-invariant";
import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import type * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { ADMIN_ID, LOHI_TOKEN_HEADER_NAME, MOD_IDS } from "./constants";
import type {
CalendarEvent,
@ -299,57 +298,19 @@ function eventStartedInThePast(
);
}
interface CanAdminTournament {
user?: Pick<User, "id">;
tournament: TournamentRepository.FindById;
}
export function isTournamentAdmin({ user, tournament }: CanAdminTournament) {
return adminOverride(user)(user?.id === tournament.author.id);
}
export function isTournamentOrganizer({
user,
tournament,
}: CanAdminTournament) {
if (isTournamentAdmin({ user, tournament })) return true;
return tournament.staff.some(
(staff) => staff.id === user?.id && staff.role === "ORGANIZER",
);
}
export function isTournamentStreamerOrOrganizer({
user,
tournament,
}: CanAdminTournament) {
if (isTournamentAdmin({ user, tournament })) return true;
return tournament.staff.some(
(staff) =>
staff.id === user?.id &&
(staff.role === "ORGANIZER" || staff.role === "STREAMER"),
);
}
export function canReportTournamentScore({
match,
user,
isMemberOfATeamInTheMatch,
tournament,
isOrganizer,
}: {
match: NonNullable<FindMatchById>;
user?: Pick<User, "id">;
isMemberOfATeamInTheMatch: boolean;
tournament: TournamentRepository.FindById;
isOrganizer: boolean;
}) {
const matchIsOver =
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
return (
!matchIsOver &&
(isMemberOfATeamInTheMatch || isTournamentOrganizer({ user, tournament }))
);
return !matchIsOver && (isMemberOfATeamInTheMatch || isOrganizer);
}
export function canAddCustomizedColorsToUserProfile(

View File

@ -256,7 +256,7 @@
gap: var(--s-6);
padding-block: var(--s-3);
padding-inline: var(--s-4);
width: fit-content;
min-width: fit-content;
-webkit-backdrop-filter: var(--backdrop-filter);
backdrop-filter: var(--backdrop-filter);
background-color: transparent;

View File

@ -38,6 +38,10 @@
color: var(--text-lighter);
}
.text-lighter-important {
color: var(--text-lighter) !important;
}
.text-error {
color: var(--theme-error);
}
@ -70,6 +74,10 @@
text-transform: capitalize;
}
.underline {
text-decoration: underline;
}
.fill-success {
fill: var(--theme-success);
}

View File

@ -64,6 +64,7 @@ html {
--s-2: 0.5rem;
--s-2-5: 0.625rem;
--s-3: 0.75rem;
--s-3-5: 0.875rem;
--s-4: 1rem;
--s-5: 1.25rem;
--s-6: 1.5rem;

View File

@ -55,7 +55,14 @@ export function isDefined<T>(value: T | undefined | null): value is T {
}
export function removeDuplicates<T>(arr: T[]): T[] {
return [...new Set(arr)];
const seen = new Set<T>();
return arr.filter((item) => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
}
export function removeDuplicatesByProperty<T>(

View File

@ -43,6 +43,17 @@ export function parseSearchParams<T extends z.ZodTypeAny>({
}
}
export function parseSafeSearchParams<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): z.SafeParseReturnType<any, z.infer<T>> {
const url = new URL(request.url);
return schema.safeParse(Object.fromEntries(url.searchParams));
}
/** Parse formData of a request with the given schema. Throws HTTP 400 response if fails. */
export async function parseRequestFormData<T extends z.ZodTypeAny>({
request,

View File

@ -233,8 +233,16 @@ export const tournamentRegisterPage = (eventId: number) =>
`/to/${eventId}/register`;
export const tournamentMapsPage = (eventId: number) => `/to/${eventId}/maps`;
export const tournamentAdminPage = (eventId: number) => `/to/${eventId}/admin`;
export const tournamentBracketsPage = (eventId: number) =>
`/to/${eventId}/brackets`;
export const tournamentBracketsPage = ({
tournamentId,
bracketIdx,
}: {
tournamentId: number;
bracketIdx?: number | null;
}) =>
`/to/${tournamentId}/brackets${
typeof bracketIdx === "number" ? `?idx=${bracketIdx}` : ""
}`;
export const tournamentBracketsSubscribePage = (eventId: number) =>
`/to/${eventId}/brackets/subscribe`;
export const tournamentMatchPage = ({

Binary file not shown.

View File

@ -8,7 +8,7 @@ export const startBracket = async (page: Page, tournamentId = 2) => {
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();

View File

@ -1,7 +1,13 @@
import { type Page, test, expect } from "@playwright/test";
import { ADMIN_DISCORD_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { impersonate, navigate, seed, submit } from "~/utils/playwright";
import {
impersonate,
isNotVisible,
navigate,
seed,
submit,
} from "~/utils/playwright";
import {
tournamentBracketsPage,
tournamentPage,
@ -9,6 +15,13 @@ import {
} from "~/utils/urls";
import { startBracket } from "./shared";
const navigateToMatch = async (page: Page, matchId: number) => {
await expect(async () => {
await page.locator(`[data-match-id="${matchId}"]`).click();
await expect(page.getByTestId("match-header")).toBeVisible();
}).toPass();
};
const reportResult = async (
page: Page,
amountOfMapsToReport: 1 | 2 | 4,
@ -58,8 +71,10 @@ const reportResult = async (
}
};
const backToBracket = (page: Page) =>
page.getByTestId("back-to-bracket-button").click();
const backToBracket = async (page: Page) => {
await page.getByTestId("back-to-bracket-button").click();
await expect(page.getByTestId("brackets-viewer")).toBeVisible();
};
const expectScore = (page: Page, score: [number, number]) =>
expect(page.getByText(score.join("-"))).toBeVisible();
@ -79,7 +94,7 @@ test.describe("Tournament bracket", () => {
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
// 1)
@ -91,31 +106,30 @@ test.describe("Tournament bracket", () => {
await impersonate(page);
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
await page.locator('[data-match-id="6"]').click();
await navigateToMatch(page, 6);
await reportResult(page, 2);
await backToBracket(page);
// 3)
await page.locator('[data-match-id="18"]').click();
await navigateToMatch(page, 18);
await reportResult(page, 1, ["first", "last"]);
await backToBracket(page);
// 4)
await page.locator('[data-match-id="5"]').click();
await page.getByTestId("reopen-match-button").click();
await expect(page.getByTestId("match-is-locked-button")).toBeVisible();
await navigateToMatch(page, 5);
await isNotVisible(page.getByTestId("reopen-match-button"));
await backToBracket(page);
// 5)
await page.locator('[data-match-id="18"]').click();
await navigateToMatch(page, 18);
await page.getByTestId("undo-score-button").click();
await expectScore(page, [0, 0]);
await backToBracket(page);
// 6)
await page.locator('[data-match-id="5"]').click();
await navigateToMatch(page, 5);
await page.getByTestId("reopen-match-button").click();
await expectScore(page, [1, 0]);
@ -123,9 +137,9 @@ test.describe("Tournament bracket", () => {
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
await page.locator('[data-match-id="5"]').click();
await navigateToMatch(page, 5);
await page.getByTestId("undo-score-button").click();
await expectScore(page, [0, 0]);
await reportResult(page, 2, ["last"], 2);
@ -145,7 +159,7 @@ test.describe("Tournament bracket", () => {
await impersonate(page, 5);
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("add-sub-button").click();
@ -191,7 +205,7 @@ test.describe("Tournament bracket", () => {
await navigate({
page,
url: tournamentBracketsPage(tournamentId),
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();

View File

@ -86,7 +86,7 @@ test.describe("Tournament staff", () => {
await navigate({
page,
url: tournamentBracketsPage(TOURNAMENT_ID),
url: tournamentBracketsPage({ tournamentId: TOURNAMENT_ID }),
});
await expect(page.getByTestId("finalize-bracket-button")).toBeVisible();

View File

@ -3,7 +3,7 @@ import invariant from "tiny-invariant";
import { ADMIN_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import type { TournamentLoaderData } from "~/features/tournament";
import type { TournamentLoaderData } from "~/features/tournament/routes/to.$id";
import type { StageId } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import {
@ -31,7 +31,7 @@ const getIsOwnerOfUser = ({
userId: number;
teamId: number;
}) => {
const team = data.teams.find((t) => t.id === teamId);
const team = data.tournament.ctx.teams.find((t) => t.id === teamId);
invariant(team, "Team not found");
return team.members.find((m) => m.userId === userId)?.isOwner;
@ -44,14 +44,14 @@ const getTeamCheckedInAt = ({
data: TournamentLoaderData;
teamId: number;
}) => {
const team = data.teams.find((t) => t.id === teamId);
const team = data.tournament.ctx.teams.find((t) => t.id === teamId);
invariant(team, "Team not found");
return team.checkedInAt;
return team.checkIns.length > 0;
};
test.describe("Tournament", () => {
test("registers for tournament", async ({ page }) => {
await seed(page, "NO_TOURNAMENT_TEAMS");
await seed(page, "REG_OPEN");
await impersonate(page);
await navigate({
@ -88,12 +88,12 @@ test.describe("Tournament", () => {
});
test("checks in and appears on the bracket", async ({ page }) => {
await seed(page);
await seed(page, "REG_OPEN");
await impersonate(page);
await navigate({
page,
url: tournamentBracketsPage(1),
url: tournamentBracketsPage({ tournamentId: 3 }),
});
await isNotVisible(page.getByText("Chimera"));
@ -102,7 +102,8 @@ test.describe("Tournament", () => {
await page.getByTestId("check-in-button").click();
await page.getByTestId("brackets-tab").click();
await page.getByText("#1 Chimera").waitFor();
await expect(page.getByTestId("brackets-viewer")).toBeVisible();
await page.getByText("Chimera").nth(0).waitFor();
});
test("operates admin controls", async ({ page }) => {
@ -150,7 +151,7 @@ test.describe("Tournament", () => {
expect(getTeamCheckedInAt({ data, teamId: 1 })).toBeFalsy();
// Remove member...
const firstTeam = data.teams.find((t) => t.id === 1);
const firstTeam = data.tournament.ctx.teams.find((t) => t.id === 1);
invariant(firstTeam, "First team not found");
const firstNonOwnerMember = firstTeam.members.find(
(m) => m.userId !== 1 && !m.isOwner,
@ -162,12 +163,12 @@ test.describe("Tournament", () => {
await submit(page);
data = await fetchTournamentLoaderData();
const firstTeamAgain = data.teams.find((t) => t.id === 1);
const firstTeamAgain = data.tournament.ctx.teams.find((t) => t.id === 1);
invariant(firstTeamAgain, "First team again not found");
expect(firstTeamAgain.members.length).toBe(firstTeam.members.length - 1);
// ...and add to another team
const teamWithSpace = data.teams.find(
const teamWithSpace = data.tournament.ctx.teams.find(
(t) => t.id !== 1 && t.members.length === 4,
);
invariant(teamWithSpace, "Team with space not found");
@ -182,7 +183,7 @@ test.describe("Tournament", () => {
await submit(page);
data = await fetchTournamentLoaderData();
const teamWithSpaceAgain = data.teams.find(
const teamWithSpaceAgain = data.tournament.ctx.teams.find(
(t) => t.id === teamWithSpace.id,
);
invariant(teamWithSpaceAgain, "Team with space again not found");
@ -197,6 +198,6 @@ test.describe("Tournament", () => {
await submit(page);
data = await fetchTournamentLoaderData();
expect(data.teams.find((t) => t.id === 1)).toBeFalsy();
expect(data.tournament.ctx.teams.find((t) => t.id === 1)).toBeFalsy();
});
});

View File

@ -0,0 +1,18 @@
module.exports.up = function (db) {
db.prepare(
/* sql */ `alter table "Tournament" add "settings" text not null default '{"bracketProgression":[{"type":"double_elimination","name":"Main bracket"}]}'`,
).run();
db.prepare(
/* sql */ `alter table "TournamentTeamCheckIn" add "bracketIdx" integer`,
).run();
db.prepare(/* sql */ `alter table "Tournament" drop column "format"`).run();
db.prepare(
/* sql */ `alter table "TournamentMatchGameResult" add "opponentOnePoints" integer`,
).run();
db.prepare(
/* sql */ `alter table "TournamentMatchGameResult" add "opponentTwoPoints" integer`,
).run();
};

View File

@ -82,11 +82,10 @@
"bracket.losers": "Losers Round {{round}}",
"bracket.losers.finals": "Losers Finals",
"bracket.single_elim": "Round {{round}}",
"bracket.round_robin": "Groups Round {{round}}",
"bracket.single_elim.finals": "Finals",
"bracket.grand_finals": "Grand Finals",
"bracket.grand_finals.bracket_reset": "Bracket Reset",
"bracket.main": "Main Bracket",
"bracket.underground": "Underground Bracket",
"actions.addSub": "Add sub",
"actions.shareLink": "Share your invite link to add members: {{inviteLink}}",
@ -104,12 +103,15 @@
"team.seed": "Seed",
"team.seed.footer": "out of {{count}}",
"team.placement": "Placement",
"team.placement.footer": "Main/UG",
"bracket.waiting": "Bracket will be shown here when at least {{count}} teams have registered",
"bracket.waiting.checkin": "Bracket will be shown here when at least {{count}} teams have checked in",
"bracket.wip": "This bracket is a preview and subject to change",
"bracket.finalize.text": "When everything looks good, finalize the bracket to start the tournament",
"bracket.finalize.text": "When everything looks good, finalize the bracket to allow matches to be played",
"bracket.finalize.action": "Finalize",
"bracket.beforeStart": "Bracket can't be started yet as it is before the start time",
"bracket.waitingForResults": "Bracket can't be started yet as it is waiting for results of the previous bracket",
"bracket.progress.thanksForPlaying": "Thanks for playing in {{eventName}}!",
"bracket.progress.match": "Current opponent: {{opponent}}",
@ -125,7 +127,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})",
"match.action.undoLastScore": "Undo last score",
"match.action.reopenMatch": "Reopen match",
"match.action.matchIsLocked": "Match is locked",
"join.error.MISSING_CODE": "Invite code is missing. Was the full URL copied?",
"join.error.SHORT_CODE": "Invite code is not the right length. Was the full URL copied?",

View File

@ -80,8 +80,6 @@
"bracket.single_elim.finals": "Finale",
"bracket.grand_finals": "Grande Finale",
"bracket.grand_finals.bracket_reset": "Bracket Reset",
"bracket.main": "Bracket Principal",
"bracket.underground": "Underground Bracket",
"actions.addSub": "Ajouter remplaçant",
"actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
@ -102,7 +100,6 @@
"bracket.waiting": "Le bracket sera affiché ici quand au moins {{count}} équipes seront inscrites",
"bracket.wip": "Ce bracket est un aperçu et sujet à changement",
"bracket.finalize.text": "Quand tout semble bon, finalisez le bracket pour commencer le tournoi",
"bracket.finalize.action": "Finaliser",
"bracket.beforeStart": "Le bracket ne peut pas encore démarrer car il est avant l'heure du début.",
@ -120,7 +117,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (Meilleur de {{bestOf}})",
"match.action.undoLastScore": "Annuler le dernier score",
"match.action.reopenMatch": "Rouvrir le match",
"match.action.matchIsLocked": "Le match est verrouillé",
"join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?",
"join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?",

View File

@ -80,8 +80,6 @@
"bracket.single_elim.finals": "גמר",
"bracket.grand_finals": "הגמר הגדול",
"bracket.grand_finals.bracket_reset": "מערך אופס",
"bracket.main": "מערך ראשי",
"bracket.underground": "מערך תת קרקעי",
"actions.addSub": "הוסיפו ממלא מקום",
"actions.shareLink": "שתפו קישור הזמנה להוספת חברי צוות: {{inviteLink}}",
@ -102,7 +100,6 @@
"bracket.waiting": "מערכים יופיעו כאן כאשר לפחות {{count}} צוותים נרשמו",
"bracket.wip": "מערך זה הוא תצוגה מקדימה ונתון לשינויים",
"bracket.finalize.text": "כשהכל נראה טוב, סיים את המערך כדי להתחיל את הטורניר",
"bracket.finalize.action": "סיימו",
"bracket.beforeStart": "מערך עדיין לא ניתן להפעלה כפי שהוא לפני שעת ההתחלה",
@ -120,7 +117,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (הטוב מ-{{bestOf}})",
"match.action.undoLastScore": "בטלו את התוצאה האחרונה",
"match.action.reopenMatch": "פתיחה מחדש של הקרב",
"match.action.matchIsLocked": "הקרב נעול",
"join.error.MISSING_CODE": "חסר קוד הזמנה. האם כתובת האתר המלאה הועתקה?",
"join.error.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?",

View File

@ -80,8 +80,6 @@
"bracket.single_elim.finals": "ファイナル",
"bracket.grand_finals": "グランド・ファイナル",
"bracket.grand_finals.bracket_reset": "ブラケットをリセット",
"bracket.main": "メイン・ブラケット",
"bracket.underground": "アンダーグラウンド・ブラケット",
"actions.addSub": "サブを追加",
"actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}",
@ -102,7 +100,6 @@
"bracket.waiting": "ブラケットは、少なくとも {{count}} チームが登録した時点で表示されます",
"bracket.wip": "このブラケットはまだプレビューで、変更される可能性があります",
"bracket.finalize.text": "全ての情報が正しいことを確認したら、トーナメントを開始するためにブラケットを確定してください",
"bracket.finalize.action": "確定",
"bracket.beforeStart": "開始時間前なのでブラケットを開始することができません",
@ -120,7 +117,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})",
"match.action.undoLastScore": "最後のスコアをやりなおす",
"match.action.reopenMatch": "対戦を再度開く",
"match.action.matchIsLocked": "対戦がロックされています",
"join.error.MISSING_CODE": "招待コードがみつかりません。すべての URL をコピーしましたか?",
"join.error.SHORT_CODE": "招待コードの長さが正しくありません。すべての URL をコピーしましたか?",

View File

@ -80,8 +80,6 @@
"bracket.single_elim.finals": "Finais",
"bracket.grand_finals": "Grandes Finais",
"bracket.grand_finals.bracket_reset": "Reset do Bracket",
"bracket.main": "Bracket Principal",
"bracket.underground": "Bracket Subterrâneo",
"actions.addSub": "Adicionar substituto(a)",
"actions.shareLink": "Compartilhe seu link de convite para adicionar membros: {{inviteLink}}",
@ -102,7 +100,6 @@
"bracket.waiting": "O bracket será mostrado aqui quando ao menos {{count}} times estiverem registrados",
"bracket.wip": "Esse bracket é uma prévia e poderá mudar",
"bracket.finalize.text": "Quando tudo parecer certo, finalize o bracket para iniciar o torneio",
"bracket.finalize.action": "Finalizar Bracket",
"bracket.beforeStart": "O bracket não pode ser iniciado pois ainda é antes da hora de início do torneio",
@ -120,7 +117,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (Melhor de {{bestOf}})",
"match.action.undoLastScore": "Desfazer última pontuação",
"match.action.reopenMatch": "Reabrir partida",
"match.action.matchIsLocked": "A partida está trancada",
"join.error.MISSING_CODE": "O código de convite está faltando. O URL foi copiado completamente?",
"join.error.SHORT_CODE": "O código de convite está com o comprimento incorreto. O URL foi copiado completamente?",

View File

@ -80,8 +80,6 @@
"bracket.single_elim.finals": "单败制决赛",
"bracket.grand_finals": "总决赛",
"bracket.grand_finals.bracket_reset": "加赛",
"bracket.main": "主对战表",
"bracket.underground": "副对战表",
"actions.addSub": "添加替补",
"actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
@ -102,7 +100,6 @@
"bracket.waiting": "至少 {{count}} 支队伍报名后,对战表将显示在这里",
"bracket.wip": "此对战表为可调整的预览",
"bracket.finalize.text": "如果没有问题,生成对战表并开始比赛",
"bracket.finalize.action": "生成",
"bracket.beforeStart": "还未到比赛开始时间,对战表目前无法开始",
@ -120,7 +117,6 @@
"match.score": "{{scoreOne}}-{{scoreTwo}} (Bo {{bestOf}})",
"match.action.undoLastScore": "撤销上次比分",
"match.action.reopenMatch": "重新开始对战",
"match.action.matchIsLocked": "对战已锁定",
"join.error.MISSING_CODE": "缺少邀请码。您是否复制了完整URL",
"join.error.SHORT_CODE": "邀请码长度不符。您是否复制了完整URL",