mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-02 00:23:57 -05:00
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:
parent
944dddae51
commit
144da5d158
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
707
app/features/tournament-bracket/core/Bracket.ts
Normal file
707
app/features/tournament-bracket/core/Bracket.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
53
app/features/tournament-bracket/core/Tournament.server.ts
Normal file
53
app/features/tournament-bracket/core/Tournament.server.ts
Normal 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));
|
||||
}
|
||||
611
app/features/tournament-bracket/core/Tournament.ts
Normal file
611
app/features/tournament-bracket/core/Tournament.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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][] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? [
|
||||
|
|
|
|||
638
app/features/tournament-bracket/core/tests/mocks.ts
Normal file
638
app/features/tournament-bracket/core/tests/mocks.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
289
app/features/tournament-bracket/core/tests/round-robin.test.ts
Normal file
289
app/features/tournament-bracket/core/tests/round-robin.test.ts
Normal 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();
|
||||
126
app/features/tournament-bracket/core/tests/test-utils.ts
Normal file
126
app/features/tournament-bracket/core/tests/test-utils.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
// TODO: tests about DE->SE underground bracket progression
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ export const loader = ({ params }: LoaderFunctionArgs) => {
|
|||
throw redirect(tournamentRegisterPage(eventId));
|
||||
}
|
||||
|
||||
throw redirect(tournamentBracketsPage(eventId));
|
||||
throw redirect(tournamentBracketsPage({ tournamentId: eventId }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
18
migrations/046-tournament-progression.js
Normal file
18
migrations/046-tournament-progression.js
Normal 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();
|
||||
};
|
||||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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 ?",
|
||||
|
|
|
|||
|
|
@ -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": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?",
|
||||
|
|
|
|||
|
|
@ -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 をコピーしましたか?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user