mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Bracket component rewrite (#1653)
* Remove old code * Add prefetching * Elim bracket initial * Hide rounds with only byes * Round hiding logic * Align stuff * Add TODO * Adjustments * Deadline * Compactify button * Simulations * Round robin bracket initial * eventId -> tournamentId * seedByTeamId removed * Couple more TODOs * RR placements table * Locking matches * Extract TournamentStream component * Bracket streams * Remove extras for tournament-manager, misc * Fix E2E tests * Fix SKALOP_SYSTEM_MESSAGE_URL in env.example * TODOs * TODO moved to GitHub * Handle team changing in match cache invalidation * Fix streamer seeing undo last score button * Show "Sub" badge on team roster page * Show who didn't play yet on match teams preview * Ranked/unranked badge * Bracket hover show roster * Add lock/unlock match test * Fix score reporting
This commit is contained in:
parent
3138e7dcae
commit
eae3d529e2
|
|
@ -25,5 +25,5 @@ TWITCH_CLIENT_ID=
|
|||
TWITCH_CLIENT_SECRET=
|
||||
|
||||
SKALOP_WS_URL=ws://localhost:5900
|
||||
SKALOP_SYSTEM_MESSAGE_URL=ws://localhost:5900/system
|
||||
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
|
||||
SKALOP_TOKEN=secret
|
||||
|
|
|
|||
9
app/components/InfoPopover.tsx
Normal file
9
app/components/InfoPopover.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Popover } from "./Popover";
|
||||
|
||||
export function InfoPopover({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Popover buttonChildren={<>?</>} triggerClassName="info-popover__trigger">
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
23
app/components/icons/Eye.tsx
Normal file
23
app/components/icons/Eye.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function EyeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/EyeSlash.tsx
Normal file
18
app/components/icons/EyeSlash.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function EyeSlashIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,16 +2,14 @@ export function LockIcon({ className }: { className?: string }) {
|
|||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
fillRule="evenodd"
|
||||
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
12
app/components/icons/Unlock.tsx
Normal file
12
app/components/icons/Unlock.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function UnlockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M18 1.5c2.9 0 5.25 2.35 5.25 5.25v3.75a.75.75 0 0 1-1.5 0V6.75a3.75 3.75 0 1 0-7.5 0v3a3 3 0 0 1 3 3v6.75a3 3 0 0 1-3 3H3.75a3 3 0 0 1-3-3v-6.75a3 3 0 0 1 3-3h9v-3c0-2.9 2.35-5.25 5.25-5.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,8 +20,8 @@ export const db = new Kysely<DB>({
|
|||
dialect: new SqliteDialect({
|
||||
database: sql,
|
||||
}),
|
||||
log: process.env.NODE_ENV === "development" ? ["query"] : undefined,
|
||||
// uncomment if you want examine the parameters of the queries
|
||||
// uncomment if you want examine the queries
|
||||
// log: process.env.NODE_ENV === "development" ? ["query"] : undefined,
|
||||
// log(event): void {
|
||||
// if (event.level === "query") {
|
||||
// console.log(event.query.sql);
|
||||
|
|
|
|||
|
|
@ -384,12 +384,24 @@ export interface TournamentSettings {
|
|||
teamsPerGroup?: number;
|
||||
}
|
||||
|
||||
export interface CastedMatchesInfo {
|
||||
/** Array for match ID's that are locked because they are pending to be casted */
|
||||
lockedMatches: number[];
|
||||
/** What matches are streamed currently & where */
|
||||
castedMatches: { twitchAccount: string; matchId: number }[];
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
settings: ColumnType<TournamentSettings, string, string>;
|
||||
id: GeneratedAlways<number>;
|
||||
mapPickingStyle: TournamentMapPickingStyle;
|
||||
showMapListGenerator: Generated<number | null>;
|
||||
castTwitchAccounts: ColumnType<string[] | null, string | null, string | null>;
|
||||
castedMatchesInfo: ColumnType<
|
||||
CastedMatchesInfo | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
}
|
||||
|
||||
export interface TournamentBadgeOwner {
|
||||
|
|
@ -406,7 +418,6 @@ export interface TournamentGroup {
|
|||
export interface TournamentMatch {
|
||||
bestOf: Generated<3 | 5 | 7>;
|
||||
chatCode: string | null;
|
||||
childCount: number;
|
||||
groupId: number;
|
||||
id: GeneratedAlways<number>;
|
||||
number: number;
|
||||
|
|
@ -459,6 +470,8 @@ export interface TournamentStage {
|
|||
settings: string;
|
||||
tournamentId: number;
|
||||
type: "double_elimination" | "single_elimination" | "round_robin";
|
||||
// not Generated<> because SQLite doesn't allow altering tables to add columns with default values :(
|
||||
createdAt: number | null;
|
||||
}
|
||||
|
||||
export interface TournamentSub {
|
||||
|
|
|
|||
|
|
@ -307,8 +307,6 @@ export enum Status {
|
|||
* Participants can be teams or individuals. */
|
||||
export interface TournamentMatch {
|
||||
id: number;
|
||||
/** Not used */
|
||||
childCount: number;
|
||||
bestOf: 3 | 5 | 7;
|
||||
roundId: number;
|
||||
stageId: number;
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
.brackets-viewer {
|
||||
/* Colors */
|
||||
--primary-background: var(--bg);
|
||||
--secondary-background: var(--bg-lightest);
|
||||
--match-background: var(--bg-lighter);
|
||||
--font-color: var(--text);
|
||||
--win-color: #50b649;
|
||||
--loss-color: #e61a1a;
|
||||
--label-color: grey;
|
||||
--hint-color: #a7a7a7;
|
||||
/* TODO: mimicking border without transparent but not pretty in light mode */
|
||||
--connector-color: #1c1b35;
|
||||
--border-color: var(--primary-background);
|
||||
--border-hover-color: transparent;
|
||||
|
||||
/* Sizes */
|
||||
--text-size: 12px;
|
||||
--round-margin: 40px;
|
||||
--match-width: 150px;
|
||||
--match-horizontal-padding: 8px;
|
||||
--match-vertical-padding: 6px;
|
||||
--connector-border-width: 2px;
|
||||
--match-border-width: 1px;
|
||||
--match-border-radius: var(--rounded-sm);
|
||||
|
||||
font-family: Lexend, sans-serif !important;
|
||||
font-weight: var(--semi-bold) !important;
|
||||
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.brackets-viewer .opponents.connect-previous::before {
|
||||
height: 52%;
|
||||
}
|
||||
|
||||
.brackets-viewer .match.connect-next.straight::after {
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.brackets-viewer h3 {
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
.brackets-viewer h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brackets-viewer h2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brackets-viewer .opponents > span {
|
||||
background-color: var(--bg-light-variation);
|
||||
top: -11px;
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
/** TODO: handle logic when to show */
|
||||
.opponents::after {
|
||||
display: none;
|
||||
content: "🔴 Live";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
right: 0;
|
||||
background-color: var(--bg-light-variation);
|
||||
color: var(--text-lighter);
|
||||
font-size: 0.8em;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.bye {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.brackets-viewer .participant .name > span {
|
||||
color: var(--theme);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brackets-viewer .participant .name::before {
|
||||
content: var(--seed);
|
||||
font-weight: initial;
|
||||
color: var(--theme);
|
||||
font-size: var(--fonts-xxs);
|
||||
margin-inline-end: var(--space-after-seed);
|
||||
}
|
||||
|
||||
.participant > .name {
|
||||
color: var(--team-text-color);
|
||||
}
|
||||
|
||||
.round > h3::after {
|
||||
content: var(--best-of-text);
|
||||
margin-inline-start: var(--s-1-5);
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import type { Bracket as BracketType } from "../../core/Bracket";
|
||||
import { Match } from "./Match";
|
||||
import { RoundHeader } from "./RoundHeader";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface EliminationBracketSideProps {
|
||||
bracket: BracketType;
|
||||
type: "winners" | "losers" | "single";
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export function EliminationBracketSide(props: EliminationBracketSideProps) {
|
||||
const tournament = useTournament();
|
||||
const rounds = getRounds(props);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="elim-bracket__container"
|
||||
style={{ "--round-count": rounds.length } as any}
|
||||
>
|
||||
{rounds.flatMap((round, roundIdx) => {
|
||||
const bestOf = tournament.ctx.bestOfs.find(
|
||||
({ roundId }) => roundId === round.id,
|
||||
)?.bestOf;
|
||||
|
||||
const matches = props.bracket.data.match.filter(
|
||||
(match) => match.round_id === round.id,
|
||||
);
|
||||
|
||||
const someMatchOngoing = matches.some(
|
||||
(match) =>
|
||||
match.opponent1 &&
|
||||
match.opponent2 &&
|
||||
match.opponent1.result !== "win" &&
|
||||
match.opponent2.result !== "win",
|
||||
);
|
||||
|
||||
if (
|
||||
!props.isExpanded &&
|
||||
// always show at least 2 rounds per side
|
||||
roundIdx < rounds.length - 2 &&
|
||||
!someMatchOngoing
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={round.id}
|
||||
className="elim-bracket__round-column"
|
||||
data-round-id={round.id}
|
||||
>
|
||||
<RoundHeader
|
||||
roundId={round.id}
|
||||
name={round.name}
|
||||
bestOf={bestOf}
|
||||
showInfos={someMatchOngoing}
|
||||
/>
|
||||
<div
|
||||
className={clsx("elim-bracket__round-matches-container", {
|
||||
"elim-bracket__round-matches-container__top-bye":
|
||||
props.type === "winners" &&
|
||||
(!props.bracket.data.match[0].opponent1 ||
|
||||
!props.bracket.data.match[0].opponent2),
|
||||
})}
|
||||
>
|
||||
{matches.map((match) => (
|
||||
<Match
|
||||
key={match.id}
|
||||
match={match}
|
||||
roundNumber={round.number}
|
||||
isPreview={props.bracket.preview}
|
||||
showSimulation={round.name !== "Bracket Reset"}
|
||||
bracket={props.bracket}
|
||||
type={
|
||||
round.name === "Grand Finals" ||
|
||||
round.name === "Bracket Reset"
|
||||
? "grands"
|
||||
: props.type === "winners"
|
||||
? "winners"
|
||||
: props.type === "losers"
|
||||
? "losers"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRounds(props: EliminationBracketSideProps) {
|
||||
const groupIds = props.bracket.data.group.flatMap((group) => {
|
||||
if (props.type === "winners" && group.number === 2) return [];
|
||||
if (props.type === "losers" && group.number !== 2) return [];
|
||||
|
||||
return group.id;
|
||||
});
|
||||
|
||||
let showingBracketReset = true;
|
||||
const rounds = props.bracket.data.round
|
||||
.flatMap((round) => {
|
||||
if (
|
||||
typeof round.group_id === "number" &&
|
||||
!groupIds.includes(round.group_id)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return round;
|
||||
})
|
||||
.filter((round, i, rounds) => {
|
||||
const isBracketReset =
|
||||
props.type === "winners" && i === rounds.length - 1;
|
||||
const grandFinalsMatch =
|
||||
props.type === "winners"
|
||||
? props.bracket.data.match.find(
|
||||
(match) => match.round_id === rounds[rounds.length - 2]?.id,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (isBracketReset && grandFinalsMatch?.opponent1?.result === "win") {
|
||||
showingBracketReset = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = props.bracket.data.match.filter(
|
||||
(match) => match.round_id === round.id,
|
||||
);
|
||||
|
||||
const atLeastOneNonByeMatch = matches.some(
|
||||
(m) => m.opponent1 && m.opponent2,
|
||||
);
|
||||
|
||||
return atLeastOneNonByeMatch;
|
||||
});
|
||||
|
||||
return rounds.map((round, i) => {
|
||||
const name = () => {
|
||||
if (
|
||||
showingBracketReset &&
|
||||
props.type === "winners" &&
|
||||
i === rounds.length - 2
|
||||
) {
|
||||
return "Grand Finals";
|
||||
}
|
||||
if (props.type === "winners" && i === rounds.length - 1) {
|
||||
return showingBracketReset ? "Bracket Reset" : "Grand Finals";
|
||||
}
|
||||
|
||||
const namePrefix =
|
||||
props.type === "winners" ? "WB " : props.type === "losers" ? "LB " : "";
|
||||
|
||||
const isFinals = i === rounds.length - (props.type === "winners" ? 3 : 1);
|
||||
const isSemis = i === rounds.length - (props.type === "winners" ? 4 : 2);
|
||||
|
||||
return `${namePrefix}${
|
||||
isFinals ? "Finals" : isSemis ? "Semis" : `Round ${i + 1}`
|
||||
}`;
|
||||
};
|
||||
|
||||
return {
|
||||
...round,
|
||||
name: name(),
|
||||
};
|
||||
});
|
||||
}
|
||||
250
app/features/tournament-bracket/components/Bracket/Match.tsx
Normal file
250
app/features/tournament-bracket/components/Bracket/Match.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import type { Unpacked } from "~/utils/types";
|
||||
import type { TournamentData } from "../../core/Tournament.server";
|
||||
import {
|
||||
useStreamingParticipants,
|
||||
useTournament,
|
||||
} from "~/features/tournament/routes/to.$id";
|
||||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
|
||||
import clsx from "clsx";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import type { Bracket } from "../../core/Bracket";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import * as React from "react";
|
||||
import type { TournamentStreamsLoader } from "~/features/tournament/routes/to.$id.streams";
|
||||
import { TournamentStream } from "~/features/tournament/components/TournamentStream";
|
||||
|
||||
interface MatchProps {
|
||||
match: Unpacked<TournamentData["data"]["match"]>;
|
||||
isPreview?: boolean;
|
||||
type?: "winners" | "losers" | "grands" | "groups";
|
||||
group?: string;
|
||||
roundNumber: number;
|
||||
showSimulation: boolean;
|
||||
bracket: Bracket;
|
||||
}
|
||||
|
||||
export function Match(props: MatchProps) {
|
||||
const isBye = !props.match.opponent1 || !props.match.opponent2;
|
||||
|
||||
if (isBye) {
|
||||
return <div className="bracket__match__bye" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<MatchHeader {...props} />
|
||||
<MatchWrapper {...props}>
|
||||
<MatchRow {...props} side={1} />
|
||||
<div className="bracket__match__separator" />
|
||||
<MatchRow {...props} side={2} />
|
||||
</MatchWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
|
||||
const tournament = useTournament();
|
||||
const streamingParticipants = useStreamingParticipants();
|
||||
|
||||
const prefix = () => {
|
||||
if (type === "winners") return "WB ";
|
||||
if (type === "losers") return "LB ";
|
||||
if (type === "grands") return "GF ";
|
||||
if (type === "groups") return `${group}`;
|
||||
return "";
|
||||
};
|
||||
|
||||
const isOver =
|
||||
match.opponent1?.result === "win" || match.opponent2?.result === "win";
|
||||
const hasStreams = () => {
|
||||
if (isOver || !match.opponent1?.id || !match.opponent2?.id) return false;
|
||||
if (
|
||||
tournament.ctx.castedMatchesInfo?.castedMatches.some(
|
||||
(cm) => cm.matchId === match.id,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
|
||||
(teamId) =>
|
||||
tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
|
||||
);
|
||||
|
||||
return streamingParticipants.some((p) => matchParticipants.includes(p));
|
||||
};
|
||||
const toBeCasted =
|
||||
!isOver &&
|
||||
tournament.ctx.castedMatchesInfo?.lockedMatches?.includes(match.id);
|
||||
|
||||
return (
|
||||
<div className="bracket__match__header">
|
||||
<div className="bracket__match__header__box">
|
||||
{prefix()}
|
||||
{roundNumber}.{match.number}
|
||||
</div>
|
||||
{hasStreams() ? (
|
||||
<Popover
|
||||
buttonChildren={<>🔴 LIVE</>}
|
||||
triggerClassName="bracket__match__header__box bracket__match__header__box__button"
|
||||
contentClassName="w-max"
|
||||
placement="top"
|
||||
>
|
||||
<MatchStreams match={match} />
|
||||
</Popover>
|
||||
) : null}
|
||||
{toBeCasted ? (
|
||||
<Popover
|
||||
buttonChildren={<>⚪ CAST</>}
|
||||
triggerClassName="bracket__match__header__box bracket__match__header__box__button"
|
||||
>
|
||||
Match is scheduled to be casted
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchWrapper({
|
||||
match,
|
||||
isPreview,
|
||||
children,
|
||||
}: MatchProps & { children: React.ReactNode }) {
|
||||
const tournament = useTournament();
|
||||
|
||||
if (!isPreview) {
|
||||
return (
|
||||
<Link
|
||||
className="bracket__match"
|
||||
to={tournamentMatchPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
matchId: match.id,
|
||||
})}
|
||||
data-match-id={match.id}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="bracket__match">{children}</div>;
|
||||
}
|
||||
|
||||
function MatchRow({
|
||||
match,
|
||||
side,
|
||||
isPreview,
|
||||
showSimulation,
|
||||
bracket,
|
||||
}: MatchProps & { side: 1 | 2 }) {
|
||||
const user = useUser();
|
||||
const tournament = useTournament();
|
||||
|
||||
const opponentKey = `opponent${side}` as const;
|
||||
const opponent = match[`opponent${side}`];
|
||||
|
||||
const score = () => {
|
||||
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
|
||||
|
||||
return opponent!.score ?? 0;
|
||||
};
|
||||
|
||||
const isLoser = opponent?.result === "loss";
|
||||
|
||||
const { team, simulated } = (() => {
|
||||
if (opponent?.id) {
|
||||
return { team: tournament.teamById(opponent.id), simulated: false };
|
||||
}
|
||||
|
||||
const simulated = showSimulation
|
||||
? bracket.simulatedMatch(match.id)
|
||||
: undefined;
|
||||
const simulatedOpponent = simulated?.[opponentKey];
|
||||
|
||||
return simulatedOpponent?.id
|
||||
? { team: tournament.teamById(simulatedOpponent.id), simulated: true }
|
||||
: { team: null, simulated: true };
|
||||
})();
|
||||
|
||||
const ownTeam = tournament.teamMemberOfByUser(user);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("stack horizontal", { "text-lighter": isLoser })}
|
||||
data-participant-id={team?.id}
|
||||
title={team?.members.map((m) => m.discordName).join(", ")}
|
||||
>
|
||||
<div
|
||||
className={clsx("bracket__match__seed", {
|
||||
"text-lighter-important italic opaque": simulated,
|
||||
})}
|
||||
>
|
||||
{team?.seed}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("bracket__match__team-name", {
|
||||
"text-theme-secondary":
|
||||
!simulated && ownTeam && ownTeam?.id === team?.id,
|
||||
"text-lighter italic opaque": simulated,
|
||||
invisible: !team,
|
||||
})}
|
||||
>
|
||||
{team?.name ?? "???"}
|
||||
</div>{" "}
|
||||
<div className="bracket__match__score">{score()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchStreams({ match }: Pick<MatchProps, "match">) {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher<TournamentStreamsLoader>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state !== "idle" || fetcher.data) return;
|
||||
fetcher.load(`/to/${tournament.ctx.id}/streams`);
|
||||
}, [fetcher, tournament.ctx.id]);
|
||||
|
||||
if (!fetcher.data || !match.opponent1?.id || !match.opponent2?.id)
|
||||
return (
|
||||
<div className="text-lighter text-center tournament-bracket__stream-popover">
|
||||
Loading streams...
|
||||
</div>
|
||||
);
|
||||
|
||||
const castingAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find(
|
||||
(cm) => cm.matchId === match.id,
|
||||
)?.twitchAccount;
|
||||
|
||||
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
|
||||
(teamId) => tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
|
||||
);
|
||||
|
||||
const streamsOfThisMatch = fetcher.data.streams.filter(
|
||||
(stream) =>
|
||||
(stream.userId && matchParticipants.includes(stream.userId)) ||
|
||||
stream.twitchUserName === castingAccount,
|
||||
);
|
||||
|
||||
if (streamsOfThisMatch.length === 0)
|
||||
return (
|
||||
<div className="tournament-bracket__stream-popover">
|
||||
After all there seems to be no streams of this match. Check the{" "}
|
||||
<Link to={tournamentStreamsPage(tournament.ctx.id)}>streams page</Link>{" "}
|
||||
for all the available streams.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack md justify-center tournament-bracket__stream-popover">
|
||||
{streamsOfThisMatch.map((stream) => (
|
||||
<TournamentStream
|
||||
key={stream.twitchUserName}
|
||||
stream={stream}
|
||||
withThumbnail={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import clsx from "clsx";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useDeadline } from "./useDeadline";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
|
||||
export function RoundHeader({
|
||||
roundId,
|
||||
name,
|
||||
bestOf,
|
||||
showInfos,
|
||||
}: {
|
||||
roundId: number;
|
||||
name: string;
|
||||
bestOf?: 3 | 5 | 7;
|
||||
showInfos?: boolean;
|
||||
}) {
|
||||
const hasDeadline = !["WB Finals", "Grand Finals", "Bracket Reset"].includes(
|
||||
name,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="elim-bracket__round-header">{name}</div>
|
||||
{showInfos && bestOf ? (
|
||||
<div className="elim-bracket__round-header__infos">
|
||||
<div>Bo{bestOf}</div>
|
||||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="elim-bracket__round-header__infos invisible">
|
||||
Hidden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Deadline({ roundId, bestOf }: { roundId: number; bestOf: 3 | 5 | 7 }) {
|
||||
useAutoRerender("ten seconds");
|
||||
const isMounted = useIsMounted();
|
||||
const deadline = useDeadline(roundId, bestOf);
|
||||
|
||||
if (!deadline) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning": isMounted && deadline < new Date(),
|
||||
})}
|
||||
>
|
||||
DL{" "}
|
||||
{deadline.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import type { Bracket as BracketType } from "../../core/Bracket";
|
||||
import { RoundHeader } from "./RoundHeader";
|
||||
import { Match } from "./Match";
|
||||
import type { Match as MatchType } from "~/modules/brackets-model";
|
||||
import { logger } from "~/utils/logger";
|
||||
import clsx from "clsx";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { tournamentTeamPage } from "~/utils/urls";
|
||||
|
||||
export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
|
||||
const groups = getGroups(bracket);
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div className="stack xl">
|
||||
{groups.map(({ groupName, groupId }) => {
|
||||
const rounds = bracket.data.round.filter((r) => r.group_id === groupId);
|
||||
|
||||
const allMatchesFinished = rounds.every((round) => {
|
||||
const matches = bracket.data.match.filter(
|
||||
(match) => match.round_id === round.id,
|
||||
);
|
||||
|
||||
return matches.every(
|
||||
(match) =>
|
||||
match.opponent1?.result === "win" ||
|
||||
match.opponent2?.result === "win",
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={groupName} className="stack lg">
|
||||
<h2 className="text-lg">{groupName}</h2>
|
||||
<div
|
||||
className="elim-bracket__container"
|
||||
style={{ "--round-count": rounds.length } as any}
|
||||
>
|
||||
{rounds.flatMap((round) => {
|
||||
const bestOf = tournament.ctx.bestOfs.find(
|
||||
({ roundId }) => roundId === round.id,
|
||||
)?.bestOf;
|
||||
|
||||
const matches = bracket.data.match.filter(
|
||||
(match) => match.round_id === round.id,
|
||||
);
|
||||
|
||||
const someMatchOngoing = matches.some(
|
||||
(match) =>
|
||||
match.opponent1 &&
|
||||
match.opponent2 &&
|
||||
match.opponent1.result !== "win" &&
|
||||
match.opponent2.result !== "win",
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={round.id} className="elim-bracket__round-column">
|
||||
<RoundHeader
|
||||
roundId={round.id}
|
||||
name={`Round ${round.number}`}
|
||||
bestOf={bestOf}
|
||||
showInfos={someMatchOngoing}
|
||||
/>
|
||||
<div className="elim-bracket__round-matches-container">
|
||||
{matches.map((match) => {
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Match
|
||||
key={match.id}
|
||||
match={match}
|
||||
roundNumber={round.number}
|
||||
isPreview={bracket.preview}
|
||||
showSimulation={false}
|
||||
bracket={bracket}
|
||||
type="groups"
|
||||
group={groupName.split(" ")[1]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PlacementsTable
|
||||
bracket={bracket}
|
||||
groupId={groupId}
|
||||
allMatchesFinished={allMatchesFinished}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getGroups(bracket: BracketType) {
|
||||
const result: Array<{
|
||||
groupName: string;
|
||||
matches: MatchType[];
|
||||
groupId: number;
|
||||
}> = [];
|
||||
|
||||
for (const group of bracket.data.group) {
|
||||
const matches = bracket.data.match.filter(
|
||||
(match) => match.group_id === group.id,
|
||||
);
|
||||
|
||||
const numberToLetter = (n: number) =>
|
||||
String.fromCharCode(65 + n - 1).toUpperCase();
|
||||
|
||||
result.push({
|
||||
groupName: `Group ${numberToLetter(group.number)}`,
|
||||
matches,
|
||||
groupId: group.id,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function PlacementsTable({
|
||||
groupId,
|
||||
bracket,
|
||||
allMatchesFinished,
|
||||
}: {
|
||||
groupId: number;
|
||||
bracket: BracketType;
|
||||
allMatchesFinished: boolean;
|
||||
}) {
|
||||
const _standings = bracket
|
||||
.currentStandings(true)
|
||||
.filter((s) => s.groupId === groupId);
|
||||
|
||||
const missingTeams = bracket.data.match.reduce((acc, cur) => {
|
||||
if (cur.group_id !== groupId) return acc;
|
||||
|
||||
if (
|
||||
cur.opponent1?.id &&
|
||||
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
|
||||
!acc.includes(cur.opponent1.id)
|
||||
) {
|
||||
acc.push(cur.opponent1.id);
|
||||
}
|
||||
|
||||
if (
|
||||
cur.opponent2?.id &&
|
||||
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
|
||||
!acc.includes(cur.opponent2.id)
|
||||
) {
|
||||
acc.push(cur.opponent2.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as number[]);
|
||||
|
||||
const standings = _standings
|
||||
.concat(
|
||||
missingTeams.map((id) => ({
|
||||
team: bracket.tournament.teamById(id)!,
|
||||
stats: {
|
||||
mapLosses: 0,
|
||||
mapWins: 0,
|
||||
points: 0,
|
||||
setLosses: 0,
|
||||
setWins: 0,
|
||||
winsAgainstTied: 0,
|
||||
},
|
||||
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
|
||||
groupId,
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.placement === b.placement && a.team.seed && b.team.seed) {
|
||||
return a.team.seed - b.team.seed;
|
||||
}
|
||||
|
||||
return a.placement - b.placement;
|
||||
});
|
||||
|
||||
const destinationBracket = (placement: number) =>
|
||||
bracket.tournament.brackets.find(
|
||||
(b) =>
|
||||
b.id !== bracket.id &&
|
||||
b.sources?.some(
|
||||
(s) => s.bracketIdx === 0 && s.placements.includes(placement),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<table className="rr__placements-table" cellSpacing={0}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th>
|
||||
<abbr title="Set wins and losses">W/L</abbr>
|
||||
</th>
|
||||
<th>
|
||||
<abbr title="Wins against tied opponents">TB</abbr>
|
||||
</th>
|
||||
<th>
|
||||
<abbr title="Map wins and losses">W/L (M)</abbr>
|
||||
</th>
|
||||
<th>
|
||||
<abbr title="Score summed up">Scr</abbr>
|
||||
</th>
|
||||
<th>Seed</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{standings.map((s, i) => {
|
||||
const stats = s.stats!;
|
||||
if (!stats) {
|
||||
logger.error("No stats for team", s.team);
|
||||
return null;
|
||||
}
|
||||
|
||||
const team = bracket.tournament.teamById(s.team.id);
|
||||
|
||||
const dest = destinationBracket(i + 1);
|
||||
|
||||
return (
|
||||
<tr key={s.team.id}>
|
||||
<td>
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
tournamentId: bracket.tournament.ctx.id,
|
||||
tournamentTeamId: s.team.id,
|
||||
})}
|
||||
>
|
||||
{s.team.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span>
|
||||
{stats.setWins}/{stats.setLosses}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{stats.winsAgainstTied}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>
|
||||
{stats.mapWins}/{stats.mapLosses}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{stats.points}</span>
|
||||
</td>
|
||||
<td>{team?.seed}</td>
|
||||
{dest ? (
|
||||
<td
|
||||
className={clsx({
|
||||
"italic text-lighter": !allMatchesFinished,
|
||||
})}
|
||||
>
|
||||
<span>→ {dest.name}</span>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
162
app/features/tournament-bracket/components/Bracket/bracket.css
Normal file
162
app/features/tournament-bracket/components/Bracket/bracket.css
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
.bracket {
|
||||
--match-width: 140px;
|
||||
--match-height: 55px;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-8);
|
||||
padding-block-end: var(--s-6);
|
||||
}
|
||||
|
||||
.bracket__match__header {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: var(--match-width);
|
||||
margin-block-start: -16px;
|
||||
}
|
||||
|
||||
.bracket__match__header__box {
|
||||
background-color: var(--bg-lightest-solid);
|
||||
padding: var(--s-0-5) var(--s-1);
|
||||
border-radius: var(--rounded-sm);
|
||||
font-size: var(--fonts-xxxs) !important;
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bracket__match__header__box__button {
|
||||
height: 18.86px;
|
||||
}
|
||||
|
||||
.bracket__match {
|
||||
width: var(--match-width);
|
||||
min-height: var(--match-height);
|
||||
max-height: var(--match-height);
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: var(--bg-light-variation);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
padding: 0 var(--s-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
color: var(--text-main);
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
a.bracket__match:hover {
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
.bracket__match__separator {
|
||||
min-height: 2px;
|
||||
max-height: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.bracket__match__bye {
|
||||
visibility: hidden;
|
||||
min-height: var(--match-height);
|
||||
max-height: var(--match-height);
|
||||
}
|
||||
|
||||
.bracket__match__seed {
|
||||
color: var(--theme);
|
||||
margin-inline-end: var(--s-0-5);
|
||||
min-width: 15px;
|
||||
max-width: 15px;
|
||||
}
|
||||
|
||||
.bracket__match__team-name {
|
||||
max-width: 95px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bracket__match__score {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.elim-bracket__container {
|
||||
--line-width: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
var(--round-count),
|
||||
calc(var(--match-width) + var(--line-width))
|
||||
);
|
||||
}
|
||||
|
||||
.elim-bracket__round-matches-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
gap: var(--s-7);
|
||||
margin-top: var(--s-6);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elim-bracket__round-matches-container__top-bye {
|
||||
margin-top: -18px;
|
||||
}
|
||||
|
||||
.elim-bracket__round-header {
|
||||
text-align: center;
|
||||
background-color: var(--bg-lightest);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
padding-block: var(--s-2);
|
||||
width: var(--match-width);
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
.elim-bracket__round-header__infos {
|
||||
width: var(--match-width);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.elim-bracket__round-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rr__placements-table {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
min-width: max-content;
|
||||
overflow-x: auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.rr__placements-table thead {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.rr__placements-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rr__placements-table th abbr {
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.rr__placements-table td span {
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.rr__placements-table tbody tr:nth-child(odd) {
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.rr__placements-table tbody tr:nth-child(even) {
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
51
app/features/tournament-bracket/components/Bracket/index.tsx
Normal file
51
app/features/tournament-bracket/components/Bracket/index.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useBracketExpanded } from "~/features/tournament/routes/to.$id";
|
||||
import type { Bracket as BracketType } from "../../core/Bracket";
|
||||
import { EliminationBracketSide } from "./Elimination";
|
||||
import { RoundRobinBracket } from "./RoundRobin";
|
||||
|
||||
export function Bracket({ bracket }: { bracket: BracketType }) {
|
||||
const { bracketExpanded } = useBracketExpanded();
|
||||
|
||||
if (bracket.type === "round_robin") {
|
||||
return (
|
||||
<BracketContainer>
|
||||
<RoundRobinBracket bracket={bracket} />
|
||||
</BracketContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (bracket.type === "single_elimination") {
|
||||
return (
|
||||
<BracketContainer>
|
||||
<EliminationBracketSide
|
||||
type="single"
|
||||
bracket={bracket}
|
||||
isExpanded={bracketExpanded}
|
||||
/>
|
||||
</BracketContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BracketContainer>
|
||||
<EliminationBracketSide
|
||||
type="winners"
|
||||
bracket={bracket}
|
||||
isExpanded={bracketExpanded}
|
||||
/>
|
||||
<EliminationBracketSide
|
||||
type="losers"
|
||||
bracket={bracket}
|
||||
isExpanded={bracketExpanded}
|
||||
/>
|
||||
</BracketContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BracketContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bracket" data-testid="brackets-viewer">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import type { Bracket } from "../../core/Bracket";
|
||||
import type { Round } from "~/modules/brackets-model";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
const MINUTES = {
|
||||
BO3: 30,
|
||||
BO5: 40,
|
||||
BO7: 50,
|
||||
};
|
||||
|
||||
export function useDeadline(roundId: number, bestOf: 3 | 5 | 7) {
|
||||
const tournament = useTournament();
|
||||
|
||||
try {
|
||||
const bracket = tournament.brackets.find((b) =>
|
||||
b.data.round.some((r) => r.id === roundId),
|
||||
);
|
||||
if (!bracket) return null;
|
||||
|
||||
const roundIdx = bracket.data.round.findIndex((r) => r.id === roundId);
|
||||
const round = bracket.data.round[roundIdx];
|
||||
if (!round) return null;
|
||||
|
||||
const isFirstRoundOfBracket =
|
||||
roundIdx === 0 || (bracket.type === "round_robin" && round.number === 1);
|
||||
|
||||
const matches = bracket.data.match.filter((m) => m.round_id === roundId);
|
||||
const everyMatchHasStarted = matches.every(
|
||||
(m) =>
|
||||
(!m.opponent1 || m.opponent1.id) && (!m.opponent2 || m.opponent2?.id),
|
||||
);
|
||||
|
||||
if (!everyMatchHasStarted) return null;
|
||||
|
||||
let dl: Date | null;
|
||||
if (isFirstRoundOfBracket) {
|
||||
// should not happen
|
||||
if (!bracket.createdAt) return null;
|
||||
|
||||
dl = databaseTimestampToDate(bracket.createdAt);
|
||||
} else {
|
||||
const losersGroupId = bracket.data.group.find((g) => g.number === 2)?.id;
|
||||
if (
|
||||
bracket.type === "single_elimination" ||
|
||||
(bracket.type === "double_elimination" &&
|
||||
round.group_id !== losersGroupId)
|
||||
) {
|
||||
dl = dateByPreviousRound(bracket, round);
|
||||
} else if (bracket.type === "round_robin") {
|
||||
dl = dateByManyPreviousRounds(bracket, round);
|
||||
} else {
|
||||
dl = dateByPreviousRoundAndWinners(bracket, round);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dl) return null;
|
||||
|
||||
dl.setMinutes(dl.getMinutes() + MINUTES[`BO${bestOf}`]);
|
||||
|
||||
return dl;
|
||||
} catch (e) {
|
||||
logger.error("useDeadline", { roundId, bestOf }, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dateByPreviousRound(bracket: Bracket, round: Round) {
|
||||
const previousRound = bracket.data.round.find(
|
||||
(r) => r.number === round.number - 1 && round.group_id === r.group_id,
|
||||
);
|
||||
if (!previousRound) {
|
||||
logger.warn("Previous round not found", { bracket, round });
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxFinishedAt = 0;
|
||||
for (const match of bracket.data.match.filter(
|
||||
(m) => m.round_id === previousRound.id,
|
||||
)) {
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
||||
return null;
|
||||
}
|
||||
|
||||
maxFinishedAt = Math.max(maxFinishedAt, match.lastGameFinishedAt ?? 0);
|
||||
}
|
||||
|
||||
if (maxFinishedAt === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return databaseTimestampToDate(maxFinishedAt);
|
||||
}
|
||||
|
||||
function dateByManyPreviousRounds(bracket: Bracket, round: Round) {
|
||||
const relevantRounds = bracket.data.round.filter(
|
||||
(r) => r.number === round.number - 1,
|
||||
);
|
||||
const allMatches = bracket.data.match.filter((match) =>
|
||||
relevantRounds.some((round) => round.id === match.round_id),
|
||||
);
|
||||
|
||||
let maxFinishedAt = 0;
|
||||
for (const match of allMatches) {
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
||||
return null;
|
||||
}
|
||||
|
||||
maxFinishedAt = Math.max(maxFinishedAt, match.lastGameFinishedAt ?? 0);
|
||||
}
|
||||
|
||||
if (maxFinishedAt === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return databaseTimestampToDate(maxFinishedAt);
|
||||
}
|
||||
|
||||
function dateByPreviousRoundAndWinners(bracket: Bracket, round: Round) {
|
||||
const byPreviousRound =
|
||||
round.number > 1 ? dateByPreviousRound(bracket, round) : null;
|
||||
const winnersRound = bracket.winnersSourceRound(round.number);
|
||||
|
||||
if (!winnersRound) return byPreviousRound;
|
||||
|
||||
let maxFinishedAtWB = 0;
|
||||
for (const match of bracket.data.match.filter(
|
||||
(m) => m.round_id === winnersRound.id,
|
||||
)) {
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
||||
return null;
|
||||
}
|
||||
|
||||
maxFinishedAtWB = Math.max(maxFinishedAtWB, match.lastGameFinishedAt ?? 0);
|
||||
}
|
||||
|
||||
if (!byPreviousRound && !maxFinishedAtWB) return null;
|
||||
if (!byPreviousRound) return databaseTimestampToDate(maxFinishedAtWB);
|
||||
if (!maxFinishedAtWB) return byPreviousRound;
|
||||
|
||||
return databaseTimestampToDate(
|
||||
Math.max(dateToDatabaseTimestamp(byPreviousRound), maxFinishedAtWB),
|
||||
);
|
||||
}
|
||||
134
app/features/tournament-bracket/components/CastInfo.tsx
Normal file
134
app/features/tournament-bracket/components/CastInfo.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useFetcher } from "@remix-run/react";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { LockIcon } from "~/components/icons/Lock";
|
||||
import { UnlockIcon } from "~/components/icons/Unlock";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
|
||||
const lockingInfo =
|
||||
"You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it.";
|
||||
const setAsCastedInfo =
|
||||
"Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.";
|
||||
|
||||
export function CastInfo({
|
||||
matchIsOngoing,
|
||||
matchId,
|
||||
hasBothParticipants,
|
||||
matchIsOver,
|
||||
}: {
|
||||
matchIsOngoing: boolean;
|
||||
matchId: number;
|
||||
hasBothParticipants: boolean;
|
||||
matchIsOver: boolean;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const tournament = useTournament();
|
||||
|
||||
const castedMatchesInfo = tournament.ctx.castedMatchesInfo;
|
||||
const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? [];
|
||||
const currentlyCastedOn = castedMatchesInfo?.castedMatches.find(
|
||||
(cm) => cm.matchId === matchId,
|
||||
)?.twitchAccount;
|
||||
const isLocked = castedMatchesInfo?.lockedMatches?.includes(matchId);
|
||||
|
||||
const hasPerms = tournament.isOrganizerOrStreamer(user);
|
||||
|
||||
if (castTwitchAccounts.length === 0 || !hasPerms || matchIsOver) return null;
|
||||
|
||||
// match has to be locked beforehand, can't be done when both participants are there already
|
||||
if (!hasBothParticipants && !isLocked) {
|
||||
return (
|
||||
<CastInfoWrapper
|
||||
submitButtonText="Lock to be casted"
|
||||
_action="LOCK"
|
||||
icon={<LockIcon />}
|
||||
infoText={lockingInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// if for some reason match is locked in the DB but also has scores reported then the UI
|
||||
// will act as if it's not locked at all
|
||||
if (!matchIsOngoing && isLocked) {
|
||||
return (
|
||||
<CastInfoWrapper
|
||||
submitButtonText="Unlock"
|
||||
_action="UNLOCK"
|
||||
icon={<UnlockIcon />}
|
||||
infoText={lockingInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CastInfoWrapper
|
||||
submitButtonText="Save"
|
||||
_action="SET_AS_CASTED"
|
||||
infoText={setAsCastedInfo}
|
||||
>
|
||||
<select
|
||||
name="twitchAccount"
|
||||
id="twitchAccount"
|
||||
defaultValue={currentlyCastedOn ?? "null"}
|
||||
data-testid="cast-info-select"
|
||||
>
|
||||
<option value="null">Not casted</option>
|
||||
{castTwitchAccounts.map((account) => (
|
||||
<option key={account} value={account}>
|
||||
{account}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CastInfoWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function CastInfoWrapper({
|
||||
children,
|
||||
icon,
|
||||
submitButtonText,
|
||||
_action,
|
||||
infoText,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
submitButtonText?: string;
|
||||
_action?: string;
|
||||
infoText?: string;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm justify-center items-center">
|
||||
<fetcher.Form
|
||||
className="tournament-bracket__cast-info-container"
|
||||
method="post"
|
||||
>
|
||||
<div className="tournament-bracket__cast-info-container__label">
|
||||
Cast
|
||||
</div>
|
||||
|
||||
<div className="stack horizontal sm items-center justify-between w-full">
|
||||
{children ? (
|
||||
<div className="tournament-bracket__cast-info-container__content">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
{submitButtonText && _action ? (
|
||||
<SubmitButton
|
||||
className="mr-2"
|
||||
state={fetcher.state}
|
||||
_action={_action}
|
||||
icon={icon}
|
||||
testId="cast-info-submit-button"
|
||||
>
|
||||
{submitButtonText}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
{infoText ? <InfoPopover>{infoText}</InfoPopover> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
|||
import { type TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
|
||||
import {
|
||||
mapCountPlayedInSetWithCertainty,
|
||||
matchIsLocked,
|
||||
resolveHostingTeam,
|
||||
resolveRoomPass,
|
||||
} from "../tournament-bracket-utils";
|
||||
|
|
@ -62,6 +63,10 @@ export function ScoreReporter({
|
|||
|
||||
const showFullInfos = !presentational && type === "EDIT";
|
||||
|
||||
const isMemberOfTeamParticipating = data.match.players.some(
|
||||
(p) => p.id === user?.id,
|
||||
);
|
||||
|
||||
const roundInfos = [
|
||||
showFullInfos ? (
|
||||
<>
|
||||
|
|
@ -110,21 +115,33 @@ export function ScoreReporter({
|
|||
stage={currentStageWithMode}
|
||||
infos={roundInfos}
|
||||
teams={teams}
|
||||
matchIsLocked={matchIsLocked({
|
||||
matchId: data.match.id,
|
||||
scores: [scoreOne, scoreTwo],
|
||||
tournament,
|
||||
})}
|
||||
>
|
||||
{currentPosition > 0 && !presentational && type === "EDIT" && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="position" value={currentPosition - 1} />
|
||||
<div className="tournament-bracket__stage-banner__bottom-bar">
|
||||
<SubmitButton
|
||||
_action="UNDO_REPORT_SCORE"
|
||||
className="tournament-bracket__stage-banner__undo-button"
|
||||
testId="undo-score-button"
|
||||
>
|
||||
{t("tournament:match.action.undoLastScore")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
{currentPosition > 0 &&
|
||||
!presentational &&
|
||||
type === "EDIT" &&
|
||||
(tournament.isOrganizer(user) || isMemberOfTeamParticipating) && (
|
||||
<Form method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="position"
|
||||
value={currentPosition - 1}
|
||||
/>
|
||||
<div className="tournament-bracket__stage-banner__bottom-bar">
|
||||
<SubmitButton
|
||||
_action="UNDO_REPORT_SCORE"
|
||||
className="tournament-bracket__stage-banner__undo-button"
|
||||
testId="undo-score-button"
|
||||
>
|
||||
{t("tournament:match.action.undoLastScore")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
{tournament.isOrganizer(user) &&
|
||||
tournament.matchCanBeReopened(data.match.id) &&
|
||||
presentational && (
|
||||
|
|
@ -178,11 +195,13 @@ function FancyStageBanner({
|
|||
infos,
|
||||
children,
|
||||
teams,
|
||||
matchIsLocked,
|
||||
}: {
|
||||
stage: TournamentMapListMap;
|
||||
infos?: (JSX.Element | null)[];
|
||||
children?: React.ReactNode;
|
||||
teams: [TournamentDataTeam, TournamentDataTeam];
|
||||
matchIsLocked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "tournament"]);
|
||||
|
||||
|
|
@ -212,33 +231,45 @@ function FancyStageBanner({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx("tournament-bracket__stage-banner", {
|
||||
rounded: !infos,
|
||||
})}
|
||||
style={style as any}
|
||||
>
|
||||
<div className="tournament-bracket__stage-banner__top-bar">
|
||||
<h4 className="tournament-bracket__stage-banner__top-bar__header">
|
||||
<Image
|
||||
className="tournament-bracket__stage-banner__top-bar__mode-image"
|
||||
path={modeImageUrl(stage.mode)}
|
||||
alt=""
|
||||
width={24}
|
||||
/>
|
||||
<span className="tournament-bracket__stage-banner__top-bar__map-text-small">
|
||||
{t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${stage.stageId}`)}
|
||||
</span>
|
||||
<span className="tournament-bracket__stage-banner__top-bar__map-text-big">
|
||||
{t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "}
|
||||
{t(`game-misc:STAGE_${stage.stageId}`)}
|
||||
</span>
|
||||
</h4>
|
||||
<h4>{pickInfoText()}</h4>
|
||||
{matchIsLocked ? (
|
||||
<div className="tournament-bracket__locked-banner">
|
||||
<div className="stack sm items-center">
|
||||
<div className="text-lg text-center font-bold">
|
||||
Match locked to be casted
|
||||
</div>
|
||||
<div>Please wait for staff to unlock</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={clsx("tournament-bracket__stage-banner", {
|
||||
rounded: !infos,
|
||||
})}
|
||||
style={style as any}
|
||||
data-testid="stage-banner"
|
||||
>
|
||||
<div className="tournament-bracket__stage-banner__top-bar">
|
||||
<h4 className="tournament-bracket__stage-banner__top-bar__header">
|
||||
<Image
|
||||
className="tournament-bracket__stage-banner__top-bar__mode-image"
|
||||
path={modeImageUrl(stage.mode)}
|
||||
alt=""
|
||||
width={24}
|
||||
/>
|
||||
<span className="tournament-bracket__stage-banner__top-bar__map-text-small">
|
||||
{t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${stage.stageId}`)}
|
||||
</span>
|
||||
<span className="tournament-bracket__stage-banner__top-bar__map-text-big">
|
||||
{t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "}
|
||||
{t(`game-misc:STAGE_${stage.stageId}`)}
|
||||
</span>
|
||||
</h4>
|
||||
<h4>{pickInfoText()}</h4>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{infos && (
|
||||
<div className="tournament-bracket__infos">
|
||||
{infos.filter(Boolean).map((info, i) => (
|
||||
|
|
@ -341,6 +372,7 @@ function MatchActionSectionTabs({
|
|||
}, [data, tournament]);
|
||||
|
||||
const showChat =
|
||||
!tournament.ctx.isFinalized &&
|
||||
data.match.chatCode &&
|
||||
(data.match.players.some((p) => p.id === user?.id) ||
|
||||
tournament.isOrganizerOrStreamer(user));
|
||||
|
|
@ -376,7 +408,7 @@ function MatchActionSectionTabs({
|
|||
const currentPosition = scores[0] + scores[1];
|
||||
|
||||
return (
|
||||
<ActionSectionWrapper>
|
||||
<ActionSectionWrapper topPadded={!showChat}>
|
||||
<NewTabs
|
||||
tabs={[
|
||||
{
|
||||
|
|
@ -440,11 +472,13 @@ function MatchActionSectionTabs({
|
|||
function ActionSectionWrapper({
|
||||
children,
|
||||
icon,
|
||||
topPadded,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: "warning" | "info" | "success" | "error";
|
||||
"justify-center"?: boolean;
|
||||
topPadded?: boolean;
|
||||
}) {
|
||||
// todo: flex-dir: column on mobile
|
||||
const style = icon
|
||||
|
|
@ -457,6 +491,7 @@ function ActionSectionWrapper({
|
|||
<div
|
||||
className={clsx("tournament__action-section__content", {
|
||||
"justify-center": rest["justify-center"],
|
||||
"pt-3": topPadded,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
import { matchIsLocked } from "../tournament-bracket-utils";
|
||||
|
||||
export function ScoreReporterRosters({
|
||||
teams,
|
||||
|
|
@ -92,6 +93,11 @@ export function ScoreReporterRosters({
|
|||
winnerName={winningTeam()}
|
||||
currentStageWithMode={currentStageWithMode}
|
||||
wouldEndSet={wouldEndSet}
|
||||
matchLocked={matchIsLocked({
|
||||
matchId: data.match.id,
|
||||
scores: scores,
|
||||
tournament,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -160,6 +166,7 @@ function ReportScoreButtons({
|
|||
winnerName,
|
||||
currentStageWithMode,
|
||||
wouldEndSet,
|
||||
matchLocked,
|
||||
}: {
|
||||
points?: [number, number];
|
||||
winnerIdx?: number;
|
||||
|
|
@ -167,9 +174,18 @@ function ReportScoreButtons({
|
|||
winnerName?: string;
|
||||
currentStageWithMode: TournamentMapListMap;
|
||||
wouldEndSet: boolean;
|
||||
matchLocked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
if (matchLocked) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
Match is pending to be casted. Please wait a bit
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (checkedPlayers.some((team) => team.length === 0)) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
|
|
@ -185,7 +201,7 @@ function ReportScoreButtons({
|
|||
) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
Winner should have more points than loser
|
||||
Winner should have higher score than loser
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -197,7 +213,7 @@ function ReportScoreButtons({
|
|||
) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
If there was a KO (100 points), other team should have 0 points
|
||||
If there was a KO (100 score), other team should have 0 score
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function TeamRosterInputs({
|
|||
const showWinnerRadio =
|
||||
!points || !presentational || winnerRadioChecked;
|
||||
|
||||
const seed = tournament.seedByTeamId(team.id);
|
||||
const seed = tournament.teamById(team.id)?.seed;
|
||||
|
||||
return (
|
||||
<div key={team.id}>
|
||||
|
|
@ -84,7 +84,7 @@ export function TeamRosterInputs({
|
|||
) : null}{" "}
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: team.id,
|
||||
})}
|
||||
className="tournament-bracket__during-match-actions__team-name"
|
||||
|
|
@ -227,7 +227,7 @@ function PointInput({
|
|||
data-testid={testId}
|
||||
/>
|
||||
<Label htmlFor={id} spaced={false}>
|
||||
Points
|
||||
Score
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import type { TournamentDataTeam } from "./Tournament.server";
|
|||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { Round } from "~/modules/brackets-model";
|
||||
import { getTournamentManager } from "..";
|
||||
|
||||
interface CreateBracketArgs {
|
||||
id: number;
|
||||
|
|
@ -18,6 +20,7 @@ interface CreateBracketArgs {
|
|||
name: string;
|
||||
teamsPendingCheckIn?: number[];
|
||||
tournament: Tournament;
|
||||
createdAt: number | null;
|
||||
sources?: {
|
||||
bracketIdx: number;
|
||||
placements: number[];
|
||||
|
|
@ -27,17 +30,28 @@ interface CreateBracketArgs {
|
|||
export interface Standing {
|
||||
team: TournamentDataTeam;
|
||||
placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th...
|
||||
groupId?: number;
|
||||
stats?: {
|
||||
setWins: number;
|
||||
setLosses: number;
|
||||
mapWins: number;
|
||||
mapLosses: number;
|
||||
points: number;
|
||||
winsAgainstTied: number;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Bracket {
|
||||
id;
|
||||
preview;
|
||||
data;
|
||||
simulatedData: ValueToArray<DataTypes> | undefined;
|
||||
canBeStarted;
|
||||
name;
|
||||
teamsPendingCheckIn;
|
||||
tournament;
|
||||
sources;
|
||||
createdAt;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
|
|
@ -48,6 +62,7 @@ export abstract class Bracket {
|
|||
teamsPendingCheckIn,
|
||||
tournament,
|
||||
sources,
|
||||
createdAt,
|
||||
}: Omit<CreateBracketArgs, "format">) {
|
||||
this.id = id;
|
||||
this.preview = preview;
|
||||
|
|
@ -57,6 +72,129 @@ export abstract class Bracket {
|
|||
this.teamsPendingCheckIn = teamsPendingCheckIn;
|
||||
this.tournament = tournament;
|
||||
this.sources = sources;
|
||||
this.createdAt = createdAt;
|
||||
|
||||
this.createdSimulation();
|
||||
}
|
||||
|
||||
private createdSimulation() {
|
||||
if (
|
||||
this.type === "round_robin" ||
|
||||
this.preview ||
|
||||
this.tournament.ctx.isFinalized
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const manager = getTournamentManager("IN_MEMORY");
|
||||
|
||||
manager.import(this.data);
|
||||
|
||||
const teamOrder = this.teamOrderForSimulation();
|
||||
|
||||
let matchesToResolve = true;
|
||||
let loopCount = 0;
|
||||
while (matchesToResolve) {
|
||||
if (loopCount > 100) {
|
||||
logger.error("Bracket.createdSimulation: loopCount > 100");
|
||||
break;
|
||||
}
|
||||
matchesToResolve = false;
|
||||
loopCount++;
|
||||
|
||||
for (const match of manager.export().match) {
|
||||
if (!match) continue;
|
||||
// we have a result already
|
||||
if (
|
||||
match.opponent1?.result === "win" ||
|
||||
match.opponent2?.result === "win"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// no opponent yet, let's simulate this in a coming loop
|
||||
if (
|
||||
(match.opponent1 && !match.opponent1.id) ||
|
||||
(match.opponent2 && !match.opponent2.id)
|
||||
) {
|
||||
matchesToResolve = true;
|
||||
continue;
|
||||
}
|
||||
// BYE
|
||||
if (match.opponent1 === null || match.opponent2 === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const winner =
|
||||
(teamOrder.get(match.opponent1.id!) ?? 0) <
|
||||
(teamOrder.get(match.opponent2.id!) ?? 0)
|
||||
? 1
|
||||
: 2;
|
||||
|
||||
manager.update.match({
|
||||
id: match.id,
|
||||
opponent1: {
|
||||
score: winner === 1 ? 1 : 0,
|
||||
result: winner === 1 ? "win" : undefined,
|
||||
},
|
||||
opponent2: {
|
||||
score: winner === 2 ? 1 : 0,
|
||||
result: winner === 2 ? "win" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.simulatedData = manager.export();
|
||||
} catch (e) {
|
||||
logger.error("Bracket.createdSimulation: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private teamOrderForSimulation() {
|
||||
const result = new Map(this.tournament.ctx.teams.map((t, i) => [t.id, i]));
|
||||
|
||||
for (const match of this.data.match) {
|
||||
if (
|
||||
!match.opponent1?.id ||
|
||||
!match.opponent2?.id ||
|
||||
(match.opponent1?.result !== "win" && match.opponent2?.result !== "win")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const opponent1Seed = result.get(match.opponent1.id) ?? -1;
|
||||
const opponent2Seed = result.get(match.opponent2.id) ?? -1;
|
||||
if (opponent1Seed === -1 || opponent2Seed === -1) {
|
||||
console.error("opponent1Seed or opponent2Seed not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opponent1Seed < opponent2Seed && match.opponent1?.result === "win") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opponent2Seed < opponent1Seed && match.opponent2?.result === "win") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opponent1Seed < opponent2Seed) {
|
||||
result.set(match.opponent1.id, opponent1Seed + 0.1);
|
||||
result.set(match.opponent2.id, opponent1Seed);
|
||||
} else {
|
||||
result.set(match.opponent2.id, opponent2Seed + 0.1);
|
||||
result.set(match.opponent1.id, opponent2Seed);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
simulatedMatch(matchId: number) {
|
||||
if (!this.simulatedData) return;
|
||||
|
||||
return this.simulatedData.match
|
||||
.filter(Boolean)
|
||||
.find((match) => match.id === matchId);
|
||||
}
|
||||
|
||||
get collectResultsWithPoints() {
|
||||
|
|
@ -71,6 +209,14 @@ export abstract class Bracket {
|
|||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
currentStandings(_includeUnfinishedGroups: boolean) {
|
||||
return this.standings;
|
||||
}
|
||||
|
||||
winnersSourceRound(_roundNumber: number): Round | undefined {
|
||||
return;
|
||||
}
|
||||
|
||||
protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] {
|
||||
return standings.map((standing) => {
|
||||
return {
|
||||
|
|
@ -245,6 +391,19 @@ class DoubleEliminationBracket extends Bracket {
|
|||
return "double_elimination";
|
||||
}
|
||||
|
||||
winnersSourceRound(roundNumber: number) {
|
||||
const isMajorRound = roundNumber === 1 || roundNumber % 2 === 0;
|
||||
if (!isMajorRound) return;
|
||||
|
||||
const roundNumberWB = Math.ceil((roundNumber + 1) / 2);
|
||||
|
||||
const groupIdWB = this.data.group.find((g) => g.number === 1)?.id;
|
||||
|
||||
return this.data.round.find(
|
||||
(round) => round.number === roundNumberWB && round.group_id === groupIdWB,
|
||||
);
|
||||
}
|
||||
|
||||
get standings(): Standing[] {
|
||||
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
|
||||
|
||||
|
|
@ -515,6 +674,10 @@ class RoundRobinBracket extends Bracket {
|
|||
}
|
||||
|
||||
get standings(): Standing[] {
|
||||
return this.currentStandings();
|
||||
}
|
||||
|
||||
currentStandings(includeUnfinishedGroups = false) {
|
||||
const groupIds = this.data.group.map((group) => group.id);
|
||||
|
||||
const placements: (Standing & { groupId: number })[] = [];
|
||||
|
|
@ -533,7 +696,7 @@ class RoundRobinBracket extends Bracket {
|
|||
match.opponent2?.result === "win",
|
||||
);
|
||||
|
||||
if (!groupIsFinished) continue;
|
||||
if (!groupIsFinished && !includeUnfinishedGroups) continue;
|
||||
|
||||
const teams: {
|
||||
id: number;
|
||||
|
|
@ -581,6 +744,13 @@ class RoundRobinBracket extends Bracket {
|
|||
};
|
||||
|
||||
for (const match of matches) {
|
||||
if (
|
||||
match.opponent1?.result !== "win" &&
|
||||
match.opponent2?.result !== "win"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const winner =
|
||||
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
|
||||
|
||||
|
|
@ -594,6 +764,7 @@ class RoundRobinBracket extends Bracket {
|
|||
typeof loser.id === "number" &&
|
||||
typeof winner.score === "number" &&
|
||||
typeof loser.score === "number",
|
||||
"RoundRobinBracket.standings: winner or loser id not found",
|
||||
);
|
||||
|
||||
if (
|
||||
|
|
@ -661,8 +832,8 @@ class RoundRobinBracket extends Bracket {
|
|||
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));
|
||||
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
|
||||
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
|
||||
|
||||
if (aSeed < bSeed) return -1;
|
||||
if (aSeed > bSeed) return 1;
|
||||
|
|
@ -674,6 +845,14 @@ class RoundRobinBracket extends Bracket {
|
|||
team: this.tournament.teamById(team.id)!,
|
||||
placement: i + 1,
|
||||
groupId,
|
||||
stats: {
|
||||
setWins: team.setWins,
|
||||
setLosses: team.setLosses,
|
||||
mapWins: team.mapWins,
|
||||
mapLosses: team.mapLosses,
|
||||
points: team.points,
|
||||
winsAgainstTied: team.winsAgainstTied,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
@ -702,6 +881,7 @@ class RoundRobinBracket extends Bracket {
|
|||
return {
|
||||
...team,
|
||||
placement: currentPlacement,
|
||||
stats: team.stats,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
|
||||
export type OptionalIdObject = { id: number } | undefined;
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ export class Tournament {
|
|||
return 1;
|
||||
}
|
||||
|
||||
return a.createdAt - b.createdAt;
|
||||
return this.compareUnseededTeams(a, b);
|
||||
});
|
||||
this.ctx = {
|
||||
...ctx,
|
||||
|
|
@ -56,6 +57,31 @@ export class Tournament {
|
|||
this.initBrackets(data);
|
||||
}
|
||||
|
||||
private compareUnseededTeams(
|
||||
a: TournamentData["ctx"]["teams"][number],
|
||||
b: TournamentData["ctx"]["teams"][number],
|
||||
) {
|
||||
const aPlus = a.members
|
||||
.flatMap((a) => (a.plusTier ? [a.plusTier] : []))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 4);
|
||||
const bPlus = b.members
|
||||
.flatMap((b) => (b.plusTier ? [b.plusTier] : []))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 4);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (aPlus[i] && !bPlus[i]) return -1;
|
||||
if (!aPlus[i] && bPlus[i]) return 1;
|
||||
|
||||
if (aPlus[i] !== bPlus[i]) {
|
||||
return aPlus[i] - bPlus[i];
|
||||
}
|
||||
}
|
||||
|
||||
return a.createdAt - b.createdAt;
|
||||
}
|
||||
|
||||
private initBrackets(data: ValueToArray<DataTypes>) {
|
||||
for (const [
|
||||
bracketIdx,
|
||||
|
|
@ -82,6 +108,7 @@ export class Tournament {
|
|||
preview: false,
|
||||
name,
|
||||
sources,
|
||||
createdAt: inProgressStage.createdAt,
|
||||
data: {
|
||||
...data,
|
||||
participant: data.participant.filter((participant) =>
|
||||
|
|
@ -117,7 +144,10 @@ export class Tournament {
|
|||
});
|
||||
|
||||
if (checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
|
||||
const seeding = checkedInTeams.map((team) => team.name);
|
||||
const seeding = checkedInTeams.map((team) => ({
|
||||
name: team.name,
|
||||
id: team.id,
|
||||
}));
|
||||
manager.create({
|
||||
tournamentId: this.ctx.id,
|
||||
name,
|
||||
|
|
@ -139,6 +169,7 @@ export class Tournament {
|
|||
data: manager.get.tournamentData(this.ctx.id),
|
||||
type,
|
||||
sources,
|
||||
createdAt: null,
|
||||
canBeStarted:
|
||||
checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START &&
|
||||
(sources ? relevantMatchesFinished : this.regularCheckInHasEnded),
|
||||
|
|
@ -233,6 +264,10 @@ export class Tournament {
|
|||
}
|
||||
}
|
||||
|
||||
get ranked() {
|
||||
return Boolean(currentSeason(this.ctx.startTime));
|
||||
}
|
||||
|
||||
get logoSrc() {
|
||||
return HACKY_resolvePicture(this.ctx);
|
||||
}
|
||||
|
|
@ -294,15 +329,11 @@ export class Tournament {
|
|||
}
|
||||
|
||||
teamById(id: number) {
|
||||
return this.ctx.teams.find((team) => team.id === id);
|
||||
}
|
||||
const teamIdx = this.ctx.teams.findIndex((team) => team.id === id);
|
||||
|
||||
seedByTeamId(id: number) {
|
||||
const idx = this.ctx.teams.findIndex((team) => team.id === id);
|
||||
if (teamIdx === -1) return;
|
||||
|
||||
if (idx === -1) return null;
|
||||
|
||||
return idx + 1;
|
||||
return { ...this.ctx.teams[teamIdx], seed: teamIdx + 1 };
|
||||
}
|
||||
|
||||
participatedPlayersByTeamId(id: number) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
TournamentTeam,
|
||||
} from "~/db/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
||||
const team_getByTournamentIdStm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
|
|
@ -64,9 +65,9 @@ const stage_getByTournamentIdStm = sql.prepare(/*sql*/ `
|
|||
const stage_insertStm = sql.prepare(/*sql*/ `
|
||||
insert into
|
||||
"TournamentStage"
|
||||
("tournamentId", "number", "name", "type", "settings")
|
||||
("tournamentId", "number", "name", "type", "settings", "createdAt")
|
||||
values
|
||||
(@tournamentId, @number, @name, @type, @settings)
|
||||
(@tournamentId, @number, @name, @type, @settings, @createdAt)
|
||||
returning *
|
||||
`);
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ export class Stage {
|
|||
name: this.name,
|
||||
type: this.type,
|
||||
settings: this.settings,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
}) as any;
|
||||
|
||||
this.id = stage.id;
|
||||
|
|
@ -345,7 +347,8 @@ const match_getByStageIdStm = sql.prepare(/*sql*/ `
|
|||
select
|
||||
"TournamentMatch".*,
|
||||
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
|
||||
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal"
|
||||
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal",
|
||||
max("TournamentMatchGameResult"."createdAt") as "lastGameFinishedAt"
|
||||
from "TournamentMatch"
|
||||
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
|
||||
where "TournamentMatch"."stageId" = @stageId
|
||||
|
|
@ -362,16 +365,15 @@ const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
|
|||
const match_insertStm = sql.prepare(/*sql*/ `
|
||||
insert into
|
||||
"TournamentMatch"
|
||||
("childCount", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode")
|
||||
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode")
|
||||
values
|
||||
(@childCount, @roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode)
|
||||
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode)
|
||||
returning *
|
||||
`);
|
||||
|
||||
const match_updateStm = sql.prepare(/*sql*/ `
|
||||
update "TournamentMatch"
|
||||
set
|
||||
"childCount" = @childCount,
|
||||
"roundId" = @roundId,
|
||||
"stageId" = @stageId,
|
||||
"groupId" = @groupId,
|
||||
|
|
@ -385,7 +387,6 @@ const match_updateStm = sql.prepare(/*sql*/ `
|
|||
|
||||
export class Match {
|
||||
id?: TournamentMatch["id"];
|
||||
childCount: TournamentMatch["childCount"];
|
||||
roundId: TournamentMatch["roundId"];
|
||||
stageId: TournamentMatch["stageId"];
|
||||
groupId: TournamentMatch["groupId"];
|
||||
|
|
@ -401,7 +402,6 @@ export class Match {
|
|||
groupId: TournamentMatch["groupId"],
|
||||
roundId: TournamentMatch["roundId"],
|
||||
number: TournamentMatch["number"],
|
||||
childCount: TournamentMatch["childCount"],
|
||||
_unknown1: null,
|
||||
_unknown2: null,
|
||||
_unknown3: null,
|
||||
|
|
@ -409,7 +409,6 @@ export class Match {
|
|||
opponentTwo: TournamentMatch["opponentTwo"],
|
||||
) {
|
||||
this.id = id;
|
||||
this.childCount = childCount;
|
||||
this.roundId = roundId;
|
||||
this.stageId = stageId;
|
||||
this.groupId = groupId;
|
||||
|
|
@ -423,11 +422,11 @@ export class Match {
|
|||
rawMatch: TournamentMatch & {
|
||||
opponentOnePointsTotal: number | null;
|
||||
opponentTwoPointsTotal: number | null;
|
||||
lastGameFinishedAt: number | null;
|
||||
},
|
||||
): MatchType {
|
||||
return {
|
||||
id: rawMatch.id,
|
||||
child_count: rawMatch.childCount,
|
||||
group_id: rawMatch.groupId,
|
||||
number: rawMatch.number,
|
||||
opponent1:
|
||||
|
|
@ -447,6 +446,7 @@ export class Match {
|
|||
round_id: rawMatch.roundId,
|
||||
stage_id: rawMatch.stageId,
|
||||
status: rawMatch.status,
|
||||
lastGameFinishedAt: rawMatch.lastGameFinishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +473,6 @@ export class Match {
|
|||
|
||||
insert() {
|
||||
const match = match_insertStm.get({
|
||||
childCount: this.childCount,
|
||||
roundId: this.roundId,
|
||||
stageId: this.stageId,
|
||||
groupId: this.groupId,
|
||||
|
|
@ -492,7 +491,6 @@ export class Match {
|
|||
update() {
|
||||
match_updateStm.run({
|
||||
id: this.id,
|
||||
childCount: this.childCount,
|
||||
roundId: this.roundId,
|
||||
stageId: this.stageId,
|
||||
groupId: this.groupId,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export class SqlDatabase {
|
|||
arg.group_id,
|
||||
arg.round_id,
|
||||
arg.number,
|
||||
arg.child_count,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
|
@ -50,8 +49,6 @@ export class SqlDatabase {
|
|||
JSON.stringify(arg.opponent2),
|
||||
);
|
||||
return match.insert() && match.id;
|
||||
|
||||
case "match_game":
|
||||
throw new Error("not implemented");
|
||||
const matchGame = new MatchGame(
|
||||
undefined,
|
||||
|
|
@ -185,8 +182,6 @@ export class SqlDatabase {
|
|||
}
|
||||
|
||||
break;
|
||||
|
||||
case "match_game":
|
||||
throw new Error("not implemented");
|
||||
if (typeof arg === "number") {
|
||||
const game = MatchGame.getById(arg);
|
||||
|
|
@ -230,7 +225,6 @@ export class SqlDatabase {
|
|||
update.group_id,
|
||||
update.round_id,
|
||||
update.number,
|
||||
update.child_count,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
|
@ -241,27 +235,7 @@ export class SqlDatabase {
|
|||
return match.update();
|
||||
}
|
||||
|
||||
if (query.stage_id)
|
||||
return Match.updateChildCountByStage(
|
||||
query.stage_id,
|
||||
update.child_count,
|
||||
);
|
||||
|
||||
if (query.group_id)
|
||||
return Match.updateChildCountByGroup(
|
||||
query.group_id,
|
||||
update.child_count,
|
||||
);
|
||||
|
||||
if (query.round_id)
|
||||
return Match.updateChildCountByRound(
|
||||
query.round_id,
|
||||
update.child_count,
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case "match_game":
|
||||
throw new Error("not implemented");
|
||||
if (typeof query === "number") {
|
||||
const game = new MatchGame(
|
||||
|
|
@ -326,8 +300,6 @@ export class SqlDatabase {
|
|||
Number.isInteger(filter.stage_id) &&
|
||||
Match.deleteByStageId(filter.stage_id)
|
||||
);
|
||||
|
||||
case "match_game":
|
||||
if (Number.isInteger(filter.stage_id))
|
||||
return MatchGame.deleteByStageId(filter.stage_id);
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
import type { Tournament, TournamentStage, TournamentTeam } from "~/db/types";
|
||||
import type { BracketsManager } from "~/modules/brackets-manager";
|
||||
import type { FinalStandingsItem } from "~/modules/brackets-manager/types";
|
||||
import type { PlayerThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server";
|
||||
import { playersThatPlayedByTournamentId } from "../queries/playersThatPlayedByTeamId.server";
|
||||
|
||||
export interface FinalStanding {
|
||||
tournamentTeam: Pick<TournamentTeam, "id" | "name">;
|
||||
placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th...
|
||||
players: PlayerThatPlayedByTeamId[];
|
||||
}
|
||||
|
||||
const STANDINGS_TO_INCLUDE = 8;
|
||||
|
||||
export function finalStandings({
|
||||
manager,
|
||||
stageId,
|
||||
tournamentId,
|
||||
includeAll,
|
||||
}: {
|
||||
manager: BracketsManager;
|
||||
stageId: TournamentStage["id"];
|
||||
tournamentId: Tournament["id"];
|
||||
includeAll?: boolean;
|
||||
}): Array<FinalStanding> | null {
|
||||
let standings: FinalStandingsItem[];
|
||||
try {
|
||||
standings = manager.get.finalStandings(stageId);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) throw e;
|
||||
|
||||
if (e.message.includes("The final match does not have a winner")) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
if (!includeAll) {
|
||||
standings = standings.slice(0, STANDINGS_TO_INCLUDE);
|
||||
}
|
||||
|
||||
const playersThatPlayed = playersThatPlayedByTournamentId(tournamentId);
|
||||
|
||||
const result: Array<FinalStanding> = [];
|
||||
|
||||
let lastRank = 1;
|
||||
let currentPlacement = 1;
|
||||
for (const [i, standing] of standings.entries()) {
|
||||
if (lastRank !== standing.rank) {
|
||||
lastRank = standing.rank;
|
||||
currentPlacement = i + 1;
|
||||
}
|
||||
result.push({
|
||||
tournamentTeam: {
|
||||
id: standing.id,
|
||||
name: standing.name,
|
||||
},
|
||||
placement: currentPlacement,
|
||||
players: playersThatPlayed.filter(
|
||||
(p) => p.tournamentTeamId === standing.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function finalStandingOfTeam({
|
||||
manager,
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
stageId,
|
||||
}: {
|
||||
manager: BracketsManager;
|
||||
tournamentId: Tournament["id"];
|
||||
tournamentTeamId: TournamentTeam["id"];
|
||||
stageId: TournamentStage["id"];
|
||||
}) {
|
||||
const standings = finalStandings({
|
||||
manager,
|
||||
tournamentId,
|
||||
includeAll: true,
|
||||
stageId,
|
||||
});
|
||||
if (!standings) return null;
|
||||
|
||||
return (
|
||||
standings.find(
|
||||
(standing) => standing.tournamentTeam.id === tournamentTeamId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,10 @@ interface ResolveCurrentMapListArgs {
|
|||
}
|
||||
|
||||
export function resolveMapList(args: ResolveCurrentMapListArgs) {
|
||||
return syncCached(String(args.matchId), () => resolveFreshMapList(args));
|
||||
// include team ids in the key to handle a case where match was reopened causing one of the teams to change
|
||||
return syncCached(`${args.matchId}-${args.teams[0]}-${args.teams[1]}`, () =>
|
||||
resolveFreshMapList(args),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFreshMapList(args: ResolveCurrentMapListArgs) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({
|
|||
inGameName: "test",
|
||||
isOwner: 0,
|
||||
plusTier: null,
|
||||
createdAt: 0,
|
||||
userId,
|
||||
})),
|
||||
name: "Team " + teamId,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
settings: {
|
||||
groupCount: 1,
|
||||
roundRobinMode: "simple",
|
||||
matchesChildCount: 0,
|
||||
size: 4,
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
},
|
||||
|
|
@ -51,7 +50,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 0,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 0,
|
||||
|
|
@ -68,7 +66,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 0,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 2,
|
||||
|
|
@ -85,7 +82,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 1,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 1,
|
||||
|
|
@ -102,7 +98,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 1,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 0,
|
||||
|
|
@ -119,7 +114,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 2,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 2,
|
||||
|
|
@ -136,7 +130,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 2,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 1,
|
||||
|
|
@ -148,7 +141,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
},
|
||||
},
|
||||
],
|
||||
match_game: [],
|
||||
participant: [
|
||||
{
|
||||
id: 0,
|
||||
|
|
@ -185,7 +177,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
groupCount: 1,
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
roundRobinMode: "simple",
|
||||
matchesChildCount: 0,
|
||||
size: 5,
|
||||
},
|
||||
},
|
||||
|
|
@ -236,7 +227,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 0,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 4,
|
||||
|
|
@ -253,7 +243,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 1,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 0,
|
||||
|
|
@ -270,7 +259,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 1,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 4,
|
||||
|
|
@ -287,7 +275,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 2,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 1,
|
||||
|
|
@ -304,7 +291,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 2,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 0,
|
||||
|
|
@ -321,7 +307,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 3,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 2,
|
||||
|
|
@ -338,7 +323,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 3,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 1,
|
||||
|
|
@ -355,7 +339,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 4,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 3,
|
||||
|
|
@ -372,7 +355,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 4,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 2,
|
||||
|
|
@ -389,7 +371,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 0,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 3,
|
||||
|
|
@ -401,7 +382,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray<DataTypes> => ({
|
|||
},
|
||||
},
|
||||
],
|
||||
match_game: [],
|
||||
participant: [
|
||||
{
|
||||
id: 0,
|
||||
|
|
@ -443,7 +423,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
groupCount: 2,
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
roundRobinMode: "simple",
|
||||
matchesChildCount: 0,
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
|
|
@ -505,7 +484,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 0,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 4,
|
||||
|
|
@ -522,7 +500,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 1,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 0,
|
||||
|
|
@ -539,7 +516,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 0,
|
||||
round_id: 2,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 3,
|
||||
|
|
@ -556,7 +532,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 1,
|
||||
round_id: 3,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 5,
|
||||
|
|
@ -573,7 +548,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 1,
|
||||
round_id: 4,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 1,
|
||||
|
|
@ -590,7 +564,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
stage_id: 0,
|
||||
group_id: 1,
|
||||
round_id: 5,
|
||||
child_count: 0,
|
||||
status: 2,
|
||||
opponent1: {
|
||||
id: 2,
|
||||
|
|
@ -602,7 +575,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray<DataTypes> => ({
|
|||
},
|
||||
},
|
||||
],
|
||||
match_game: [],
|
||||
participant: [
|
||||
{
|
||||
id: 0,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const roundRobinTournamentCtx: Partial<TournamentData["ctx"]> = {
|
|||
bracketProgression: [{ name: BRACKET_NAMES.GROUPS, type: "round_robin" }],
|
||||
},
|
||||
inProgressBrackets: [
|
||||
{ id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS },
|
||||
{ id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS, createdAt: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ export const testTournament = (
|
|||
id: stage.id,
|
||||
name: stage.name,
|
||||
type: stage.type,
|
||||
createdAt: 0,
|
||||
})),
|
||||
castedMatchesInfo: null,
|
||||
bestOfs: data.round.map((round) => ({ bestOf: 3, roundId: round.id })),
|
||||
teams: nTeams(
|
||||
data.participant.length,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { everyMatchIsOver } from "./tournament-bracket-utils";
|
||||
export { getTournamentManager } from "./core/brackets-manager";
|
||||
export { finalStandingOfTeam } from "./core/finalStandings.server";
|
||||
export { findMapPoolByTeamId } from "./queries/findMapPoolByTeamId.server";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { ActionFunction, LinksFunction } from "@remix-run/node";
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
useFetcher,
|
||||
useNavigate,
|
||||
useRevalidator,
|
||||
} from "@remix-run/react";
|
||||
import { Form, Link, useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -19,7 +13,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|||
import { Popover } from "~/components/Popover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { sql } from "~/db/sql";
|
||||
import { Status } from "~/db/types";
|
||||
import { requireUser, useUser } from "~/features/auth/core";
|
||||
import {
|
||||
currentSeason,
|
||||
|
|
@ -38,12 +31,13 @@ import {
|
|||
SENDOU_INK_BASE_URL,
|
||||
tournamentBracketsSubscribePage,
|
||||
tournamentJoinPage,
|
||||
tournamentMatchPage,
|
||||
tournamentTeamPage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { useTournament } from "../../tournament/routes/to.$id";
|
||||
import bracketViewerStyles from "../brackets-viewer.css";
|
||||
import {
|
||||
useBracketExpanded,
|
||||
useTournament,
|
||||
} from "../../tournament/routes/to.$id";
|
||||
import { tournamentFromDB } from "../core/Tournament.server";
|
||||
import { resolveBestOfs } from "../core/bestOf.server";
|
||||
import { getTournamentManager } from "../core/brackets-manager";
|
||||
|
|
@ -58,27 +52,27 @@ import {
|
|||
fillWithNullTillPowerOfTwo,
|
||||
} from "../tournament-bracket-utils";
|
||||
import bracketStyles from "../tournament-bracket.css";
|
||||
import bracketComponentStyles from "../components/Bracket/bracket.css";
|
||||
import type { Standing } from "../core/Bracket";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
|
||||
import { Bracket } from "../components/Bracket";
|
||||
import { EyeIcon } from "~/components/icons/Eye";
|
||||
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://cdn.jsdelivr.net/npm/brackets-viewer@1.5.1/dist/brackets-viewer.min.css",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: bracketViewerStyles,
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: bracketStyles,
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: bracketComponentStyles,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -207,8 +201,6 @@ export default function TournamentBracketsPage() {
|
|||
name: "idx",
|
||||
revive: Number,
|
||||
});
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const tournament = useTournament();
|
||||
|
||||
const bracket = React.useMemo(
|
||||
|
|
@ -216,91 +208,6 @@ export default function TournamentBracketsPage() {
|
|||
[tournament, bracketIdx],
|
||||
);
|
||||
|
||||
// TODO: bracket i18n
|
||||
React.useEffect(() => {
|
||||
if (!bracket.enoughTeams) return;
|
||||
|
||||
// matches aren't generated before tournament starts
|
||||
if (!bracket.preview) {
|
||||
// @ts-expect-error - brackets-viewer is not typed
|
||||
window.bracketsViewer.onMatchClicked = (match) => {
|
||||
// can't view match page of a bye
|
||||
if (match.opponent1 === null || match.opponent2 === null) {
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
tournamentMatchPage({
|
||||
eventId: tournament.ctx.id,
|
||||
matchId: match.id,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error - brackets-viewer is not typed
|
||||
window.bracketsViewer.render(
|
||||
{
|
||||
stages: bracket.data.stage,
|
||||
matches: bracket.data.match,
|
||||
matchGames: bracket.data.match_game,
|
||||
participants: bracket.data.participant,
|
||||
},
|
||||
{
|
||||
customRoundName: (info: any) => {
|
||||
if (info.groupType === "final-group" && info.roundNumber === 1) {
|
||||
return "Grand Finals";
|
||||
}
|
||||
if (info.groupType === "final-group" && info.roundNumber === 2) {
|
||||
return "Bracket Reset";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
separatedChildCountLabel: true,
|
||||
},
|
||||
);
|
||||
|
||||
// my beautiful hack to show seeds
|
||||
// clean up probably not needed as it's not harmful to append more than one
|
||||
const cssRulesToAppend = tournament.ctx.teams.map((team, i) => {
|
||||
const participantId = tournament.hasStarted ? team.id : i;
|
||||
return /* css */ `
|
||||
[data-participant-id="${participantId}"] {
|
||||
--seed: "${i + 1} ";
|
||||
--space-after-seed: ${i < 9 ? "6px" : "0px"};
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
const ownTeam = tournament.teamMemberOfByUser(user);
|
||||
if (ownTeam) {
|
||||
cssRulesToAppend.push(/* css */ `
|
||||
[title="${ownTeam.name}"] {
|
||||
--team-text-color: var(--theme-secondary);
|
||||
}
|
||||
`);
|
||||
}
|
||||
if (tournament.ctx.bestOfs) {
|
||||
for (const { bestOf, roundId } of tournament.ctx.bestOfs) {
|
||||
cssRulesToAppend.push(/* css */ `
|
||||
[data-round-id="${roundId}"] {
|
||||
--best-of-text: "Bo${bestOf}";
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
appendStyleTagToHead(cssRulesToAppend.join("\n"));
|
||||
|
||||
const element = ref.current;
|
||||
return () => {
|
||||
if (!element) return;
|
||||
|
||||
element.innerHTML = "";
|
||||
// @ts-expect-error - brackets-viewer is not typed
|
||||
window.bracketsViewer!.onMatchClicked = () => {};
|
||||
};
|
||||
}, [navigate, bracket, tournament, user]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visibility !== "visible" || tournament.everyBracketOver) return;
|
||||
|
||||
|
|
@ -428,12 +335,15 @@ export default function TournamentBracketsPage() {
|
|||
{tournament.ctx.isFinalized || tournament.canFinalize(user) ? (
|
||||
<FinalStandings />
|
||||
) : null}
|
||||
<BracketNav bracketIdx={bracketIdx} setBracketIdx={setBracketIdx} />
|
||||
<div
|
||||
className="brackets-viewer"
|
||||
ref={ref}
|
||||
data-testid="brackets-viewer"
|
||||
/>
|
||||
<div className="stack md">
|
||||
<div className="stack horizontal sm">
|
||||
<BracketNav bracketIdx={bracketIdx} setBracketIdx={setBracketIdx} />
|
||||
{bracket.type !== "round_robin" && !bracket.preview ? (
|
||||
<CompactifyButton />
|
||||
) : null}
|
||||
</div>
|
||||
{bracket.enoughTeams ? <Bracket bracket={bracket} /> : null}
|
||||
</div>
|
||||
{!bracket.enoughTeams ? (
|
||||
<div>
|
||||
<div className="text-center text-lg font-semi-bold text-lighter mt-6">
|
||||
|
|
@ -456,16 +366,6 @@ function AutoRefresher() {
|
|||
return null;
|
||||
}
|
||||
|
||||
function appendStyleTagToHead(content: string) {
|
||||
const head = document.head || document.getElementsByTagName("head")[0];
|
||||
const style = document.createElement("style");
|
||||
|
||||
head.appendChild(style);
|
||||
|
||||
style.type = "text/css";
|
||||
style.appendChild(document.createTextNode(content));
|
||||
}
|
||||
|
||||
function useAutoRefresh() {
|
||||
const { revalidate } = useRevalidator();
|
||||
const tournament = useTournament();
|
||||
|
|
@ -479,31 +379,8 @@ function useAutoRefresh() {
|
|||
React.useEffect(() => {
|
||||
if (!lastEvent) return;
|
||||
|
||||
const [matchIdRaw, scoreOneRaw, scoreTwoRaw, isOverRaw] =
|
||||
lastEvent.split("-");
|
||||
const matchId = Number(matchIdRaw);
|
||||
const scoreOne = Number(scoreOneRaw);
|
||||
const scoreTwo = Number(scoreTwoRaw);
|
||||
const isOver = isOverRaw === "true";
|
||||
|
||||
if (isOver) {
|
||||
// bracketsViewer.updateMatch can't advance bracket
|
||||
// so we revalidate loader when the match is over
|
||||
revalidate();
|
||||
} else {
|
||||
// TODO: shows 1 - "-" when updating match where other score is 0
|
||||
// @ts-expect-error - brackets-viewer is not typed
|
||||
window.bracketsViewer.updateMatch({
|
||||
id: matchId,
|
||||
opponent1: {
|
||||
score: scoreOne,
|
||||
},
|
||||
opponent2: {
|
||||
score: scoreTwo,
|
||||
},
|
||||
status: Status.Running,
|
||||
});
|
||||
}
|
||||
// TODO: maybe later could look into not revalidating unless bracket advanced but do something fancy in the tournament class instead
|
||||
revalidate();
|
||||
}, [lastEvent, revalidate]);
|
||||
}
|
||||
|
||||
|
|
@ -538,7 +415,7 @@ function AddSubsPopOver() {
|
|||
tournament.maxTeamMemberCount - ownedTeam.members.length;
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
inviteCode: ownedTeam.inviteCode,
|
||||
})}`;
|
||||
|
||||
|
|
@ -571,12 +448,16 @@ function AddSubsPopOver() {
|
|||
);
|
||||
}
|
||||
|
||||
const MAX_PLACEMENT_TO_SHOW = 7;
|
||||
|
||||
function FinalStandings() {
|
||||
const tournament = useTournament();
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const [viewAll, setViewAll] = React.useState(false);
|
||||
|
||||
const standings = tournament.standings;
|
||||
const standings = tournament.standings.filter(
|
||||
(s) => s.placement <= MAX_PLACEMENT_TO_SHOW,
|
||||
);
|
||||
|
||||
if (standings.length < 2) {
|
||||
console.error("Unexpectedly few standings");
|
||||
|
|
@ -613,7 +494,7 @@ function FinalStandings() {
|
|||
</div>
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: standing.team.id,
|
||||
})}
|
||||
className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big"
|
||||
|
|
@ -671,7 +552,7 @@ function FinalStandings() {
|
|||
>
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: standing.team.id,
|
||||
})}
|
||||
className="tournament-bracket__standing__team-name"
|
||||
|
|
@ -749,7 +630,7 @@ function BracketNav({
|
|||
if (tournament.ctx.settings.bracketProgression.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="stack sm horizontal flex-wrap">
|
||||
<div className="tournament-bracket__bracket-nav">
|
||||
{tournament.ctx.settings.bracketProgression.map((bracket, i) => {
|
||||
// underground bracket was never played despite being in the format
|
||||
if (
|
||||
|
|
@ -762,17 +643,32 @@ function BracketNav({
|
|||
return (
|
||||
<Button
|
||||
key={bracket.name}
|
||||
variant="minimal"
|
||||
onClick={() => setBracketIdx(i)}
|
||||
className={clsx("text-xs", {
|
||||
"text-theme underline": bracketIdx === i,
|
||||
"text-lighter-important": bracketIdx !== i,
|
||||
className={clsx("tournament-bracket__bracket-nav__link", {
|
||||
"tournament-bracket__bracket-nav__link__selected":
|
||||
bracketIdx === i,
|
||||
})}
|
||||
>
|
||||
{bracket.name}
|
||||
{bracket.name.replace("bracket", "")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactifyButton() {
|
||||
const { bracketExpanded, setBracketExpanded } = useBracketExpanded();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBracketExpanded(!bracketExpanded);
|
||||
}}
|
||||
className="tournament-bracket__compactify-button"
|
||||
icon={bracketExpanded ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
>
|
||||
{bracketExpanded ? "Compactify" : "Show all"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,9 +42,12 @@ import { matchSchema } from "../tournament-bracket-schemas.server";
|
|||
import {
|
||||
bracketSubscriptionKey,
|
||||
matchIdFromParams,
|
||||
matchIsLocked,
|
||||
matchSubscriptionKey,
|
||||
} from "../tournament-bracket-utils";
|
||||
import bracketStyles from "../tournament-bracket.css";
|
||||
import { CastInfo } from "../components/CastInfo";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{
|
||||
|
|
@ -103,6 +106,10 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
"Winner team id is invalid",
|
||||
);
|
||||
validate(match.opponentOne && match.opponentTwo, "Teams are missing");
|
||||
validate(
|
||||
!matchIsLocked({ matchId: match.id, tournament, scores }),
|
||||
"Match is locked",
|
||||
);
|
||||
|
||||
const mapList =
|
||||
match.opponentOne?.id && match.opponentTwo?.id
|
||||
|
|
@ -259,6 +266,42 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case "SET_AS_CASTED": {
|
||||
validate(tournament.isOrganizerOrStreamer(user));
|
||||
|
||||
await TournamentRepository.setMatchAsCasted({
|
||||
matchId: match.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
twitchAccount: data.twitchAccount,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "LOCK": {
|
||||
validate(tournament.isOrganizerOrStreamer(user));
|
||||
|
||||
// can't lock, let's update their view to reflect that
|
||||
if (match.opponentOne?.id && match.opponentTwo?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await TournamentRepository.lockMatch({
|
||||
matchId: match.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "UNLOCK": {
|
||||
validate(tournament.isOrganizerOrStreamer(user));
|
||||
|
||||
await TournamentRepository.unlockMatch({
|
||||
matchId: match.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
|
|
@ -364,20 +407,35 @@ export default function TournamentMatchPage() {
|
|||
Back to bracket
|
||||
</LinkButton>
|
||||
</div>
|
||||
{data.matchIsOver ? <ResultsSection /> : null}
|
||||
{!data.matchIsOver &&
|
||||
typeof data.match.opponentOne?.id === "number" &&
|
||||
typeof data.match.opponentTwo?.id === "number" ? (
|
||||
<MapListSection
|
||||
teams={[data.match.opponentOne.id, data.match.opponentTwo.id]}
|
||||
type={type}
|
||||
<div className="stack md">
|
||||
<CastInfo
|
||||
matchIsOngoing={Boolean(
|
||||
(data.match.opponentOne?.score &&
|
||||
data.match.opponentOne.score > 0) ||
|
||||
(data.match.opponentTwo?.score &&
|
||||
data.match.opponentTwo.score > 0),
|
||||
)}
|
||||
matchIsOver={data.matchIsOver}
|
||||
matchId={data.match.id}
|
||||
hasBothParticipants={Boolean(
|
||||
data.match.opponentOne?.id && data.match.opponentTwo?.id,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{showRosterPeek() ? (
|
||||
<Rosters
|
||||
teams={[data.match.opponentOne?.id, data.match.opponentTwo?.id]}
|
||||
/>
|
||||
) : null}
|
||||
{data.matchIsOver ? <ResultsSection /> : null}
|
||||
{!data.matchIsOver &&
|
||||
typeof data.match.opponentOne?.id === "number" &&
|
||||
typeof data.match.opponentTwo?.id === "number" ? (
|
||||
<MapListSection
|
||||
teams={[data.match.opponentOne.id, data.match.opponentTwo.id]}
|
||||
type={type}
|
||||
/>
|
||||
) : null}
|
||||
{showRosterPeek() ? (
|
||||
<Rosters
|
||||
teams={[data.match.opponentOne?.id, data.match.opponentTwo?.id]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -394,7 +452,7 @@ function useAutoRefresh() {
|
|||
const data = useLoaderData<typeof loader>();
|
||||
const lastEventId = useEventSource(
|
||||
tournamentMatchSubscribePage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
matchId: data.match.id,
|
||||
}),
|
||||
{
|
||||
|
|
@ -479,6 +537,8 @@ function ResultsSection() {
|
|||
);
|
||||
}
|
||||
|
||||
const INACTIVE_PLAYER_CSS =
|
||||
"tournament__team-with-roster__member__inactive text-lighter-important";
|
||||
function Rosters({
|
||||
teams,
|
||||
}: {
|
||||
|
|
@ -496,6 +556,13 @@ function Rosters({
|
|||
(p) => p.tournamentTeamId === teamTwo?.id,
|
||||
);
|
||||
|
||||
const teamOneParticipatedPlayers = teamOnePlayers.filter((p) =>
|
||||
tournament.ctx.participatedUsers.includes(p.id),
|
||||
);
|
||||
const teamTwoParticipatedPlayers = teamTwoPlayers.filter((p) =>
|
||||
tournament.ctx.participatedUsers.includes(p.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tournament-bracket__rosters">
|
||||
<div>
|
||||
|
|
@ -511,7 +578,7 @@ function Rosters({
|
|||
{teamOne ? (
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: teamOne.id,
|
||||
})}
|
||||
className="text-main-forced font-bold"
|
||||
|
|
@ -527,7 +594,17 @@ function Rosters({
|
|||
{teamOnePlayers.map((p) => {
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link to={userPage(p)} className="stack horizontal sm">
|
||||
<Link
|
||||
to={userPage(p)}
|
||||
className={clsx("stack horizontal sm", {
|
||||
[INACTIVE_PLAYER_CSS]:
|
||||
teamOneParticipatedPlayers.length === 0 ||
|
||||
teamOneParticipatedPlayers.every(
|
||||
(participatedPlayer) =>
|
||||
p.id !== participatedPlayer.id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<Avatar user={p} size="xxs" />
|
||||
{p.discordName}
|
||||
</Link>
|
||||
|
|
@ -546,7 +623,7 @@ function Rosters({
|
|||
{teamTwo ? (
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: teamTwo.id,
|
||||
})}
|
||||
className="text-main-forced font-bold"
|
||||
|
|
@ -562,7 +639,17 @@ function Rosters({
|
|||
{teamTwoPlayers.map((p) => {
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link to={userPage(p)} className="stack horizontal sm">
|
||||
<Link
|
||||
to={userPage(p)}
|
||||
className={clsx("stack horizontal sm", {
|
||||
[INACTIVE_PLAYER_CSS]:
|
||||
teamTwoParticipatedPlayers.length === 0 ||
|
||||
teamTwoParticipatedPlayers.every(
|
||||
(participatedPlayer) =>
|
||||
p.id !== participatedPlayer.id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<Avatar user={p} size="xxs" />
|
||||
{p.discordName}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { _action, id, safeJSONParse } from "~/utils/zod";
|
||||
import { _action, id, nullLiteraltoNull, safeJSONParse } from "~/utils/zod";
|
||||
import { TOURNAMENT } from "../tournament/tournament-constants";
|
||||
|
||||
const reportedMatchPlayerIds = z.preprocess(
|
||||
|
|
@ -53,6 +53,19 @@ export const matchSchema = z.union([
|
|||
z.object({
|
||||
_action: _action("REOPEN_MATCH"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("SET_AS_CASTED"),
|
||||
twitchAccount: z.preprocess(
|
||||
nullLiteraltoNull,
|
||||
z.string().min(1).max(100).nullable(),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("LOCK"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UNLOCK"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const bracketIdx = z.coerce.number().int().min(0).max(2);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
|
||||
import type { TournamentDataTeam } from "./core/Tournament.server";
|
||||
import type { Tournament } from "./core/Tournament";
|
||||
|
||||
export function matchIdFromParams(params: Params<string>) {
|
||||
const result = Number(params["mid"]);
|
||||
|
|
@ -159,3 +160,19 @@ export function everyBracketOver(tournament: ValueToArray<DataTypes>) {
|
|||
|
||||
export const bracketHasStarted = (bracket: ValueToArray<DataTypes>) =>
|
||||
bracket.stage[0] && bracket.stage[0].id !== 0;
|
||||
|
||||
export function matchIsLocked({
|
||||
tournament,
|
||||
matchId,
|
||||
scores,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
matchId: number;
|
||||
scores: [number, number];
|
||||
}) {
|
||||
if (scores[0] !== 0 || scores[1] !== 0) return false;
|
||||
|
||||
const locked = tournament.ctx.castedMatchesInfo?.lockedMatches ?? [];
|
||||
|
||||
return locked.includes(matchId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@
|
|||
font-size: var(--fonts-xxs);
|
||||
}
|
||||
|
||||
.tournament-bracket__locked-banner {
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
background-color: var(--bg-lightest-solid);
|
||||
border-start-end-radius: var(--rounded);
|
||||
border-start-start-radius: var(--rounded);
|
||||
grid-area: img;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tournament-bracket__stage-banner {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
|
@ -226,6 +237,7 @@
|
|||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-inline: var(--s-2);
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.tournament-bracket__during-match-actions__seed {
|
||||
|
|
@ -382,3 +394,84 @@
|
|||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link {
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
border-color: var(--bg-lightest-solid);
|
||||
background-color: var(--bg);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link:active {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link:first-of-type {
|
||||
border-start-start-radius: var(--rounded);
|
||||
border-end-start-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link:not(
|
||||
.tournament-bracket__bracket-nav__link:first-of-type
|
||||
) {
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link:last-of-type {
|
||||
border-start-end-radius: var(--rounded);
|
||||
border-end-end-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.tournament-bracket__bracket-nav__link__selected {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.tournament-bracket__compactify-button {
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
border-color: var(--bg-lightest-solid);
|
||||
background-color: var(--bg);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.tournament-bracket__compactify-button .button-icon {
|
||||
width: 0.85rem;
|
||||
}
|
||||
|
||||
.tournament-bracket__cast-info-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tournament-bracket__cast-info-container__label {
|
||||
padding: var(--s-2) var(--s-3-5);
|
||||
text-transform: uppercase;
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded) 0 0 var(--rounded);
|
||||
font-weight: var(--bold);
|
||||
color: var(--text-lighter);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tournament-bracket__cast-info-container__content {
|
||||
padding-block: var(--s-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tournament-bracket__stream-popover {
|
||||
width: 280px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NotNull } from "kysely";
|
||||
import type { NotNull, Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { CastedMatchesInfo, DB, Tables } from "~/db/tables";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ export async function findById(id: number) {
|
|||
"Tournament.settings",
|
||||
"Tournament.showMapListGenerator",
|
||||
"Tournament.castTwitchAccounts",
|
||||
"Tournament.castedMatchesInfo",
|
||||
"Tournament.mapPickingStyle",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.description",
|
||||
|
|
@ -53,6 +54,7 @@ export async function findById(id: number) {
|
|||
"TournamentStage.id",
|
||||
"TournamentStage.name",
|
||||
"TournamentStage.type",
|
||||
"TournamentStage.createdAt",
|
||||
])
|
||||
.where("TournamentStage.tournamentId", "=", id)
|
||||
.orderBy("TournamentStage.number asc"),
|
||||
|
|
@ -82,6 +84,7 @@ export async function findById(id: number) {
|
|||
"User.country",
|
||||
"PlusTier.tier as plusTier",
|
||||
"TournamentTeamMember.isOwner",
|
||||
"TournamentTeamMember.createdAt",
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
|
|
@ -315,3 +318,121 @@ export function updateCastTwitchAccounts({
|
|||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const castedMatchesInfoByTournamentId = async (
|
||||
trx: Transaction<DB>,
|
||||
tournamentId: number,
|
||||
) =>
|
||||
(
|
||||
await trx
|
||||
.selectFrom("Tournament")
|
||||
.select("castedMatchesInfo")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow()
|
||||
).castedMatchesInfo ??
|
||||
({
|
||||
castedMatches: [],
|
||||
lockedMatches: [],
|
||||
} as CastedMatchesInfo);
|
||||
|
||||
export function lockMatch({
|
||||
matchId,
|
||||
tournamentId,
|
||||
}: {
|
||||
matchId: number;
|
||||
tournamentId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const castedMatchesInfo = await castedMatchesInfoByTournamentId(
|
||||
trx,
|
||||
tournamentId,
|
||||
);
|
||||
|
||||
if (!castedMatchesInfo.lockedMatches.includes(matchId)) {
|
||||
castedMatchesInfo.lockedMatches.push(matchId);
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("Tournament")
|
||||
.set({
|
||||
castedMatchesInfo: JSON.stringify(castedMatchesInfo),
|
||||
})
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function unlockMatch({
|
||||
matchId,
|
||||
tournamentId,
|
||||
}: {
|
||||
matchId: number;
|
||||
tournamentId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const castedMatchesInfo = await castedMatchesInfoByTournamentId(
|
||||
trx,
|
||||
tournamentId,
|
||||
);
|
||||
|
||||
castedMatchesInfo.lockedMatches = castedMatchesInfo.lockedMatches.filter(
|
||||
(lockedMatchId) => lockedMatchId !== matchId,
|
||||
);
|
||||
|
||||
await trx
|
||||
.updateTable("Tournament")
|
||||
.set({
|
||||
castedMatchesInfo: JSON.stringify(castedMatchesInfo),
|
||||
})
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function setMatchAsCasted({
|
||||
matchId,
|
||||
tournamentId,
|
||||
twitchAccount,
|
||||
}: {
|
||||
matchId: number;
|
||||
tournamentId: number;
|
||||
twitchAccount: string | null;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const castedMatchesInfo = await castedMatchesInfoByTournamentId(
|
||||
trx,
|
||||
tournamentId,
|
||||
);
|
||||
|
||||
let newCastedMatchesInfo: CastedMatchesInfo;
|
||||
if (twitchAccount === null) {
|
||||
newCastedMatchesInfo = {
|
||||
...castedMatchesInfo,
|
||||
castedMatches: castedMatchesInfo.castedMatches.filter(
|
||||
(cm) => cm.matchId !== matchId,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
newCastedMatchesInfo = {
|
||||
...castedMatchesInfo,
|
||||
castedMatches: castedMatchesInfo.castedMatches
|
||||
.filter(
|
||||
(cm) =>
|
||||
// currently a match can only be streamed by one account
|
||||
// and a cast can only stream one match at a time
|
||||
// these can change in the future
|
||||
cm.matchId !== matchId && cm.twitchAccount !== twitchAccount,
|
||||
)
|
||||
.concat([{ twitchAccount, matchId }]),
|
||||
};
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("Tournament")
|
||||
.set({
|
||||
castedMatchesInfo: JSON.stringify(newCastedMatchesInfo),
|
||||
})
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ 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";
|
||||
import { useTournament } from "../routes/to.$id";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
export function TeamWithRoster({
|
||||
team,
|
||||
|
|
@ -19,6 +21,8 @@ export function TeamWithRoster({
|
|||
teamPageUrl?: string;
|
||||
activePlayers?: User["id"][];
|
||||
}) {
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="tournament__team-with-roster">
|
||||
|
|
@ -29,36 +33,47 @@ export function TeamWithRoster({
|
|||
{teamPageUrl ? <Link to={teamPageUrl}>{team.name}</Link> : team.name}
|
||||
</div>
|
||||
<ul className="tournament__team-with-roster__members">
|
||||
{team.members.map((member) => (
|
||||
<li
|
||||
key={member.userId}
|
||||
className={clsx("tournament__team-with-roster__member", {
|
||||
"tournament__team-with-roster__member__inactive":
|
||||
activePlayers && !activePlayers.includes(member.userId),
|
||||
})}
|
||||
>
|
||||
<Avatar
|
||||
user={member}
|
||||
size="xxs"
|
||||
className={
|
||||
activePlayers && !activePlayers.includes(member.userId)
|
||||
? "tournament__team-with-roster__member__avatar-inactive"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="tournament__team-member-name"
|
||||
{team.members.map((member) => {
|
||||
const isSub =
|
||||
databaseTimestampToDate(member.createdAt) >
|
||||
tournament.ctx.startTime;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={member.userId}
|
||||
className={clsx("tournament__team-with-roster__member", {
|
||||
"tournament__team-with-roster__member__inactive":
|
||||
activePlayers && !activePlayers.includes(member.userId),
|
||||
})}
|
||||
>
|
||||
{member.discordName}{" "}
|
||||
{member.isOwner ? (
|
||||
<span className="tournament__team-member-name__captain">
|
||||
(C)
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<Avatar
|
||||
user={member}
|
||||
size="xxs"
|
||||
className={
|
||||
activePlayers && !activePlayers.includes(member.userId)
|
||||
? "tournament__team-with-roster__member__avatar-inactive"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="tournament__team-member-name"
|
||||
>
|
||||
{member.discordName}{" "}
|
||||
{member.isOwner ? (
|
||||
<span className="tournament__team-member-name__role text-theme">
|
||||
(C)
|
||||
</span>
|
||||
) : null}
|
||||
{isSub ? (
|
||||
<span className="tournament__team-member-name__role text-info">
|
||||
Sub
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{mapPool && mapPool.length > 0 ? <TeamMapPool mapPool={mapPool} /> : null}
|
||||
|
|
|
|||
67
app/features/tournament/components/TournamentStream.tsx
Normal file
67
app/features/tournament/components/TournamentStream.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
|
||||
import { twitchUrl } from "~/utils/urls";
|
||||
import type { TournamentStreamsLoader } from "../routes/to.$id.streams";
|
||||
import { useTournament } from "../routes/to.$id";
|
||||
|
||||
export function TournamentStream({
|
||||
stream,
|
||||
withThumbnail = true,
|
||||
}: {
|
||||
stream: SerializeFrom<TournamentStreamsLoader>["streams"][number];
|
||||
withThumbnail?: boolean;
|
||||
}) {
|
||||
const tournament = useTournament();
|
||||
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);
|
||||
|
||||
return (
|
||||
<div key={stream.userId} className="stack sm">
|
||||
{withThumbnail ? (
|
||||
<a
|
||||
href={twitchUrl(stream.twitchUserName)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src={twitchThumbnailUrlToSrc(stream.thumbnailUrl)}
|
||||
width={320}
|
||||
height={180}
|
||||
/>
|
||||
</a>
|
||||
) : null}
|
||||
<div className="stack md horizontal justify-between">
|
||||
{user && team ? (
|
||||
<div className="tournament__stream__user-container">
|
||||
<Avatar size="xxs" user={user} /> {user.discordName}
|
||||
<span className="text-theme-secondary">{team.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tournament__stream__user-container">
|
||||
<Avatar size="xxs" url={tournament.logoSrc} />
|
||||
Cast <span className="text-lighter">{stream.twitchUserName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tournament__stream__viewer-count">
|
||||
<UserIcon />
|
||||
{stream.viewerCount}
|
||||
</div>
|
||||
</div>
|
||||
{!withThumbnail ? (
|
||||
<a
|
||||
href={twitchUrl(stream.twitchUserName)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xxs text-semi-bold text-center"
|
||||
>
|
||||
Watch now
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -270,7 +270,23 @@ export default function TournamentRegisterPage() {
|
|||
/>
|
||||
<div>
|
||||
<div className="tournament__title">{tournament.ctx.name}</div>
|
||||
<div className="tournament__by">
|
||||
<div className="stack horizontal sm">
|
||||
{tournament.ranked ? (
|
||||
<div className="tournament__badge tournament__badge__ranked">
|
||||
Ranked
|
||||
</div>
|
||||
) : (
|
||||
<div className="tournament__badge tournament__badge__unranked">
|
||||
Unranked
|
||||
</div>
|
||||
)}
|
||||
<div className="tournament__badge tournament__badge__modes">
|
||||
{tournament.modesIncluded.map((mode) => (
|
||||
<ModeImage key={mode} mode={mode} size={16} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tournament__by mt-1">
|
||||
<div className="stack horizontal xs items-center">
|
||||
<UserIcon className="tournament__info__icon" />{" "}
|
||||
{tournament.ctx.author.discordName}
|
||||
|
|
@ -287,13 +303,6 @@ export default function TournamentRegisterPage() {
|
|||
})
|
||||
: null}
|
||||
</div>
|
||||
<div className="stack horizontal sm mt-1">
|
||||
{tournament.modesIncluded.map((mode) => (
|
||||
<div key={mode} className="tournament___info__mode-container">
|
||||
<ModeImage mode={mode} size={18} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -640,7 +649,7 @@ function FillRoster({
|
|||
const { t } = useTranslation(["common", "tournament"]);
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
inviteCode: ownTeam.inviteCode!,
|
||||
})}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
|
||||
import { tournamentRegisterPage, twitchUrl } from "~/utils/urls";
|
||||
import { tournamentRegisterPage } from "~/utils/urls";
|
||||
import * as TournamentRepository from "../TournamentRepository.server";
|
||||
import { streamsByTournamentId } from "../core/streams.server";
|
||||
import { tournamentIdFromParams } from "../tournament-utils";
|
||||
import { useTournament } from "./to.$id";
|
||||
import { TournamentStream } from "../components/TournamentStream";
|
||||
|
||||
export type TournamentStreamsLoader = typeof loader;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
|
|
@ -45,46 +45,9 @@ export default function TournamentStreamsPage() {
|
|||
// TODO: link to user page, later tournament team page?
|
||||
return (
|
||||
<div className="stack horizontal lg flex-wrap justify-center">
|
||||
{data.streams.map((stream) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<div key={stream.userId} className="stack sm">
|
||||
<a
|
||||
href={twitchUrl(stream.twitchUserName)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src={twitchThumbnailUrlToSrc(stream.thumbnailUrl)}
|
||||
width={320}
|
||||
height={180}
|
||||
/>
|
||||
</a>
|
||||
<div className="stack horizontal justify-between">
|
||||
{user && team ? (
|
||||
<div className="tournament__stream__user-container">
|
||||
<Avatar size="xxs" user={user} /> {user.discordName}
|
||||
<span className="text-theme-secondary">{team.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tournament__stream__user-container">
|
||||
Cast
|
||||
<span className="text-lighter">{stream.twitchUserName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tournament__stream__viewer-count">
|
||||
<UserIcon />
|
||||
{stream.viewerCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.streams.map((stream) => (
|
||||
<TournamentStream key={stream.twitchUserName} stream={stream} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) {
|
|||
<Link
|
||||
to={tournamentMatchPage({
|
||||
matchId: set.tournamentMatchId,
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
})}
|
||||
className="tournament__team__set__round-name"
|
||||
>
|
||||
|
|
@ -235,7 +235,7 @@ function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) {
|
|||
<Link
|
||||
to={tournamentTeamPage({
|
||||
tournamentTeamId: set.opponent.id,
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
})}
|
||||
className="tournament__team__set__opponent__team"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function TournamentTeamsPage() {
|
|||
team={team}
|
||||
seed={i + 1}
|
||||
teamPageUrl={tournamentTeamPage({
|
||||
eventId: tournament.ctx.id,
|
||||
tournamentId: tournament.ctx.id,
|
||||
tournamentTeamId: team.id,
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -75,24 +75,24 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
|
||||
const tournament = await tournamentData({ tournamentId, user });
|
||||
|
||||
const streams =
|
||||
tournament.ctx.inProgressBrackets.length > 0
|
||||
? await streamsByTournamentId({
|
||||
tournamentId,
|
||||
castTwitchAccounts: tournament.ctx.castTwitchAccounts,
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
tournament,
|
||||
subsCount,
|
||||
streamsCount:
|
||||
tournament.ctx.inProgressBrackets.length > 0
|
||||
? (
|
||||
await streamsByTournamentId({
|
||||
tournamentId,
|
||||
castTwitchAccounts: tournament.ctx.castTwitchAccounts,
|
||||
})
|
||||
).length
|
||||
: 0,
|
||||
streamingParticipants: streams.flatMap((s) => (s.userId ? [s.userId] : [])),
|
||||
streamsCount: streams.length,
|
||||
};
|
||||
};
|
||||
|
||||
const TournamentContext = React.createContext<Tournament>(null!);
|
||||
|
||||
// TODO: icons to nav could be nice
|
||||
export default function TournamentLayout() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const user = useUser();
|
||||
|
|
@ -102,6 +102,7 @@ export default function TournamentLayout() {
|
|||
() => new Tournament(data.tournament),
|
||||
[data],
|
||||
);
|
||||
const [bracketExpanded, setBracketExpanded] = React.useState(true);
|
||||
|
||||
// this is nice to debug with tournament in browser console
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
|
@ -118,17 +119,21 @@ export default function TournamentLayout() {
|
|||
<Main bigger={onBracketsPage}>
|
||||
<SubNav>
|
||||
{!tournament.hasStarted ? (
|
||||
<SubNavLink to="register" data-testid="register-tab">
|
||||
<SubNavLink
|
||||
to="register"
|
||||
data-testid="register-tab"
|
||||
prefetch="render"
|
||||
>
|
||||
{t("tournament:tabs.register")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
<SubNavLink to="brackets" data-testid="brackets-tab">
|
||||
<SubNavLink to="brackets" data-testid="brackets-tab" prefetch="render">
|
||||
{t("tournament:tabs.brackets")}
|
||||
</SubNavLink>
|
||||
{tournament.ctx.showMapListGenerator ? (
|
||||
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
|
||||
) : null}
|
||||
<SubNavLink to="teams" end={false}>
|
||||
<SubNavLink to="teams" end={false} prefetch="render">
|
||||
{t("tournament:tabs.teams", { count: tournament.ctx.teams.length })}
|
||||
</SubNavLink>
|
||||
{!tournament.everyBracketOver && tournament.subsFeatureEnabled && (
|
||||
|
|
@ -138,7 +143,9 @@ export default function TournamentLayout() {
|
|||
)}
|
||||
{tournament.hasStarted && !tournament.everyBracketOver ? (
|
||||
<SubNavLink to="streams">
|
||||
{t("tournament:tabs.streams", { count: data.streamsCount })}
|
||||
{t("tournament:tabs.streams", {
|
||||
count: data.streamsCount,
|
||||
})}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{tournament.isOrganizer(user) && !tournament.hasStarted && (
|
||||
|
|
@ -151,12 +158,39 @@ export default function TournamentLayout() {
|
|||
)}
|
||||
</SubNav>
|
||||
<TournamentContext.Provider value={tournament}>
|
||||
<Outlet context={tournament satisfies Tournament} />
|
||||
<Outlet
|
||||
context={
|
||||
{
|
||||
tournament,
|
||||
bracketExpanded,
|
||||
setBracketExpanded,
|
||||
streamingParticipants: data.streamingParticipants,
|
||||
} satisfies TournamentContext
|
||||
}
|
||||
/>
|
||||
</TournamentContext.Provider>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
type TournamentContext = {
|
||||
tournament: Tournament;
|
||||
bracketExpanded: boolean;
|
||||
streamingParticipants: number[];
|
||||
setBracketExpanded: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
export function useTournament() {
|
||||
return useOutletContext<Tournament>();
|
||||
return useOutletContext<TournamentContext>().tournament;
|
||||
}
|
||||
|
||||
export function useBracketExpanded() {
|
||||
const { bracketExpanded, setBracketExpanded } =
|
||||
useOutletContext<TournamentContext>();
|
||||
|
||||
return { bracketExpanded, setBracketExpanded };
|
||||
}
|
||||
|
||||
export function useStreamingParticipants() {
|
||||
return useOutletContext<TournamentContext>().streamingParticipants;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.tournament__action-section__top-padded {
|
||||
padding: var(--s-3) var(--s-6) var(--s-6) var(--s-6);
|
||||
}
|
||||
|
||||
.tournament__action-section-title {
|
||||
font-size: var(--fonts-lg);
|
||||
font-weight: var(--bold);
|
||||
|
|
@ -175,10 +179,9 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tournament__team-member-name__captain {
|
||||
.tournament__team-member-name__role {
|
||||
font-size: var(--fonts-xxxs);
|
||||
color: var(--theme);
|
||||
font-weight: var(--semi-bold);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.tournament__logo-container {
|
||||
|
|
@ -196,17 +199,38 @@
|
|||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.tournament__badge {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
padding: var(--s-0-5) var(--s-2);
|
||||
border-radius: var(--rounded);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: max-content;
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.tournament__badge__ranked {
|
||||
background-color: var(--theme-info-transparent);
|
||||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.tournament__badge__unranked {
|
||||
background-color: var(--theme-success-transparent);
|
||||
color: var(--theme-success);
|
||||
}
|
||||
|
||||
.tournament__badge__modes {
|
||||
background-color: var(--bg-lighter-transparent);
|
||||
}
|
||||
|
||||
.tournament__info__icon {
|
||||
width: 18px;
|
||||
padding: var(--s-1) 0;
|
||||
}
|
||||
|
||||
.tournament___info__mode-container {
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.tournament__by {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function UserResultsTable({
|
|||
{result.tournamentId ? (
|
||||
<Link
|
||||
to={tournamentTeamPage({
|
||||
eventId: result.tournamentId,
|
||||
tournamentId: result.tournamentId,
|
||||
tournamentTeamId: result.teamId,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import * as React from "react";
|
||||
|
||||
/** Forces the component to rerender every second */
|
||||
export function useAutoRerender() {
|
||||
/** Forces the component to rerender periodically*/
|
||||
export function useAutoRerender(every?: "second" | "ten seconds") {
|
||||
const [, setNow] = React.useState(new Date().getTime());
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalTime = !every || every === "second" ? 1000 : 10000;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNow(new Date().getTime());
|
||||
}, 1000);
|
||||
}, intervalTime);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [every]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { DeepPartial, Storage, RoundPositionalInfo } from "../types";
|
||||
import type { Storage, RoundPositionalInfo } from "../types";
|
||||
import type {
|
||||
Group,
|
||||
Match,
|
||||
MatchGame,
|
||||
Round,
|
||||
SeedOrdering,
|
||||
Stage,
|
||||
|
|
@ -704,29 +703,4 @@ export class BaseGetter {
|
|||
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`.
|
||||
*
|
||||
* @param game Values to change in a match game.
|
||||
*/
|
||||
protected findMatchGame(game: DeepPartial<MatchGame>): MatchGame {
|
||||
if (game.id !== undefined) {
|
||||
const stored = this.storage.select("match_game", game.id);
|
||||
if (!stored) throw Error("Match game not found.");
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (game.parent_id !== undefined && game.number) {
|
||||
const stored = this.storage.selectFirst("match_game", {
|
||||
parent_id: game.parent_id,
|
||||
number: game.number,
|
||||
});
|
||||
|
||||
if (!stored) throw Error("Match game not found.");
|
||||
return stored;
|
||||
}
|
||||
|
||||
throw Error("No match game id nor parent id and number given.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type {
|
||||
Match,
|
||||
MatchGame,
|
||||
Seeding,
|
||||
Stage,
|
||||
GroupType,
|
||||
|
|
@ -76,33 +75,6 @@ export class BaseUpdater extends BaseGetter {
|
|||
create.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a parent match based on its child games.
|
||||
*
|
||||
* @param parentId ID of the parent match.
|
||||
* @param inRoundRobin Indicates whether the parent match is in a round-robin stage.
|
||||
*/
|
||||
protected updateParentMatch(parentId: number, inRoundRobin: boolean): void {
|
||||
const storedParent = this.storage.select("match", parentId);
|
||||
if (!storedParent) throw Error("Parent not found.");
|
||||
|
||||
const games = this.storage.select("match_game", {
|
||||
parent_id: parentId,
|
||||
});
|
||||
if (!games) throw Error("No match games.");
|
||||
|
||||
const parentScores = helpers.getChildGamesResults(games);
|
||||
const parent = helpers.getParentMatchResults(storedParent, parentScores);
|
||||
|
||||
helpers.setParentMatchCompleted(
|
||||
parent,
|
||||
storedParent.child_count,
|
||||
inRoundRobin,
|
||||
);
|
||||
|
||||
this.updateMatch(storedParent, parent, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if a match is locked and the new seeding will change this match's participants.
|
||||
*
|
||||
|
|
@ -194,32 +166,6 @@ export class BaseUpdater extends BaseGetter {
|
|||
this.updateRelatedMatches(stored, statusChanged, resultChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a match game based on a partial match game.
|
||||
*
|
||||
* @param stored A reference to what will be updated in the storage.
|
||||
* @param game Input of the update.
|
||||
*/
|
||||
protected updateMatchGame(
|
||||
stored: MatchGame,
|
||||
game: DeepPartial<MatchGame>,
|
||||
): void {
|
||||
if (helpers.isMatchUpdateLocked(stored))
|
||||
throw Error("The match game is locked.");
|
||||
|
||||
const stage = this.storage.select("stage", stored.stage_id);
|
||||
if (!stage) throw Error("Stage not found.");
|
||||
|
||||
const inRoundRobin = helpers.isRoundRobin(stage);
|
||||
|
||||
helpers.setMatchResults(stored, game, inRoundRobin);
|
||||
|
||||
if (!this.storage.update("match_game", stored.id, stored))
|
||||
throw Error("Could not update the match game.");
|
||||
|
||||
this.updateParentMatch(stored.parent_id, inRoundRobin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the opponents and status of a match and its child games.
|
||||
*
|
||||
|
|
@ -228,26 +174,6 @@ export class BaseUpdater extends BaseGetter {
|
|||
protected applyMatchUpdate(match: Match): void {
|
||||
if (!this.storage.update("match", match.id, match))
|
||||
throw Error("Could not update the match.");
|
||||
|
||||
if (match.child_count === 0) return;
|
||||
|
||||
const updatedMatchGame: Partial<MatchGame> = {
|
||||
opponent1: helpers.toResult(match.opponent1),
|
||||
opponent2: helpers.toResult(match.opponent2),
|
||||
};
|
||||
|
||||
// Only sync the child games' status with their parent's status when changing the parent match participants
|
||||
// (Locked, Waiting, Ready).
|
||||
if (match.status <= Status.Ready) updatedMatchGame.status = match.status;
|
||||
|
||||
if (
|
||||
!this.storage.update(
|
||||
"match_game",
|
||||
{ parent_id: match.id },
|
||||
updatedMatchGame,
|
||||
)
|
||||
)
|
||||
throw Error("Could not update the match game.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type {
|
|||
Group,
|
||||
InputStage,
|
||||
Match,
|
||||
MatchGame,
|
||||
Participant,
|
||||
Round,
|
||||
Seeding,
|
||||
|
|
@ -68,9 +67,6 @@ export class Create {
|
|||
|
||||
if (stage.type === "double_elimination")
|
||||
this.stage.settings.grandFinal = this.stage.settings.grandFinal || "none";
|
||||
|
||||
this.stage.settings.matchesChildCount =
|
||||
this.stage.settings.matchesChildCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -393,8 +389,6 @@ export class Create {
|
|||
matchCount: number,
|
||||
duels: Duel[],
|
||||
): void {
|
||||
const matchesChildCount = this.getMatchesChildCount();
|
||||
|
||||
const roundId = this.insertRound({
|
||||
number: roundNumber,
|
||||
stage_id: stageId,
|
||||
|
|
@ -404,28 +398,17 @@ export class Create {
|
|||
if (roundId === -1) throw Error("Could not insert the round.");
|
||||
|
||||
for (let i = 0; i < matchCount; i++)
|
||||
this.createMatch(
|
||||
stageId,
|
||||
groupId,
|
||||
roundId,
|
||||
i + 1,
|
||||
duels[i],
|
||||
matchesChildCount,
|
||||
);
|
||||
this.createMatch(stageId, groupId, roundId, i + 1, duels[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a match, possibly with match games.
|
||||
*
|
||||
* - If `childCount` is 0, then there is no children. The score of the match is directly its intrinsic score.
|
||||
* - If `childCount` is greater than 0, then the score of the match will automatically be calculated based on its child games.
|
||||
*
|
||||
* @param stageId ID of the parent stage.
|
||||
* @param groupId ID of the parent group.
|
||||
* @param roundId ID of the parent round.
|
||||
* @param matchNumber Number in the round.
|
||||
* @param opponents The two opponents matching against each other.
|
||||
* @param childCount Child count for this match (number of games).
|
||||
*/
|
||||
private createMatch(
|
||||
stageId: number,
|
||||
|
|
@ -433,7 +416,6 @@ export class Create {
|
|||
roundId: number,
|
||||
matchNumber: number,
|
||||
opponents: Duel,
|
||||
childCount: number,
|
||||
): void {
|
||||
const opponent1 = helpers.toResultWithPosition(opponents[0]);
|
||||
const opponent2 = helpers.toResultWithPosition(opponents[1]);
|
||||
|
|
@ -455,10 +437,6 @@ export class Create {
|
|||
number: matchNumber,
|
||||
});
|
||||
|
||||
const currentChildCount = existing?.child_count;
|
||||
childCount =
|
||||
currentChildCount === undefined ? childCount : currentChildCount;
|
||||
|
||||
if (existing) {
|
||||
// Keep the most advanced status when updating a match.
|
||||
const existingStatus = helpers.getMatchStatus(existing);
|
||||
|
|
@ -472,7 +450,6 @@ export class Create {
|
|||
stage_id: stageId,
|
||||
group_id: groupId,
|
||||
round_id: roundId,
|
||||
child_count: childCount,
|
||||
status: status,
|
||||
opponent1,
|
||||
opponent2,
|
||||
|
|
@ -481,19 +458,6 @@ export class Create {
|
|||
);
|
||||
|
||||
if (parentId === -1) throw Error("Could not insert the match.");
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const id = this.insertMatchGame({
|
||||
number: i + 1,
|
||||
stage_id: stageId,
|
||||
parent_id: parentId,
|
||||
status: status,
|
||||
opponent1: helpers.toResult(opponents[0]),
|
||||
opponent2: helpers.toResult(opponents[1]),
|
||||
});
|
||||
|
||||
if (id === -1) throw Error("Could not insert the match game.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -682,15 +646,6 @@ export class Create {
|
|||
return maxNumber + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets `matchesChildCount` in the stage input settings.
|
||||
*/
|
||||
private getMatchesChildCount(): number {
|
||||
if (!this.stage.settings?.matchesChildCount) return 0;
|
||||
|
||||
return this.stage.settings.matchesChildCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets an ordering by its index in the stage input settings.
|
||||
*
|
||||
|
|
@ -896,34 +851,6 @@ export class Create {
|
|||
return existing.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a match game or finds an existing one (and updates it).
|
||||
*
|
||||
* @param matchGame The match game to insert.
|
||||
*/
|
||||
private insertMatchGame(matchGame: OmitId<MatchGame>): number {
|
||||
let existing: MatchGame | null = null;
|
||||
|
||||
if (this.updateMode) {
|
||||
existing = this.storage.selectFirst("match_game", {
|
||||
parent_id: matchGame.parent_id,
|
||||
number: matchGame.number,
|
||||
});
|
||||
}
|
||||
|
||||
if (!existing) return this.storage.insert("match_game", matchGame);
|
||||
|
||||
const updated = helpers.getUpdatedMatchResults(
|
||||
matchGame,
|
||||
existing,
|
||||
this.enableByesInUpdate,
|
||||
) as MatchGame;
|
||||
if (!this.storage.update("match_game", existing.id, updated))
|
||||
throw Error("Could not update the match game.");
|
||||
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts missing participants.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ export class Delete {
|
|||
public stage(stageId: number): void {
|
||||
// The order is important here, because the abstract storage can possibly have foreign key checks (e.g. SQL).
|
||||
|
||||
if (!this.storage.delete("match_game", { stage_id: stageId }))
|
||||
throw Error("Could not delete match games.");
|
||||
|
||||
if (!this.storage.delete("match", { stage_id: stageId }))
|
||||
throw Error("Could not delete matches.");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Group, Match, MatchGame } from "~/modules/brackets-model";
|
||||
import type { Group, Match } from "~/modules/brackets-model";
|
||||
import { BaseGetter } from "./base/getter";
|
||||
import * as helpers from "./helpers";
|
||||
|
||||
|
|
@ -152,13 +152,4 @@ export class Find extends BaseGetter {
|
|||
): Match {
|
||||
return this.findMatch(groupId, roundNumber, matchNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`.
|
||||
*
|
||||
* @param game Values to change in a match game.
|
||||
*/
|
||||
public matchGame(game: Partial<MatchGame>): MatchGame {
|
||||
return this.findMatchGame(game);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
Group,
|
||||
Round,
|
||||
Match,
|
||||
MatchGame,
|
||||
Participant,
|
||||
} from "~/modules/brackets-model";
|
||||
import { Status } from "~/modules/brackets-model";
|
||||
|
|
@ -30,7 +29,6 @@ export class Get extends BaseGetter {
|
|||
group: stageData.groups,
|
||||
round: stageData.rounds,
|
||||
match: stageData.matches,
|
||||
match_game: stageData.matchGames,
|
||||
participant: participants,
|
||||
};
|
||||
}
|
||||
|
|
@ -69,31 +67,10 @@ export class Get extends BaseGetter {
|
|||
(acc, data) => [...acc, ...data.matches],
|
||||
[] as Match[],
|
||||
),
|
||||
match_game: stagesData.reduce(
|
||||
(acc, data) => [...acc, ...data.matchGames],
|
||||
[] as MatchGame[],
|
||||
),
|
||||
participant: participants,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the match games associated to a list of matches.
|
||||
*
|
||||
* @param matches A list of matches.
|
||||
*/
|
||||
public matchGames(matches: Match[]): MatchGame[] {
|
||||
const parentMatches = matches.filter((match) => match.child_count > 0);
|
||||
|
||||
const matchGamesQueries = parentMatches.map((match) =>
|
||||
this.storage.select("match_game", { parent_id: match.id }),
|
||||
);
|
||||
if (matchGamesQueries.some((game) => game === null))
|
||||
throw Error("Error getting match games.");
|
||||
|
||||
return helpers.getNonNull(matchGamesQueries).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stage that is not completed yet, because of uncompleted matches.
|
||||
* If all matches are completed in this tournament, there is no "current stage", so `null` is returned.
|
||||
|
|
@ -443,7 +420,6 @@ export class Get extends BaseGetter {
|
|||
groups: Group[];
|
||||
rounds: Round[];
|
||||
matches: Match[];
|
||||
matchGames: MatchGame[];
|
||||
} {
|
||||
const stage = this.storage.select("stage", stageId);
|
||||
if (!stage) throw Error("Stage not found.");
|
||||
|
|
@ -457,14 +433,11 @@ export class Get extends BaseGetter {
|
|||
const matches = this.storage.select("match", { stage_id: stageId });
|
||||
if (!matches) throw Error("Error getting matches.");
|
||||
|
||||
const matchGames = this.matchGames(matches);
|
||||
|
||||
return {
|
||||
stage,
|
||||
groups,
|
||||
rounds,
|
||||
matches,
|
||||
matchGames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type {
|
||||
GrandFinalType,
|
||||
Match,
|
||||
MatchGame,
|
||||
MatchResults,
|
||||
Participant,
|
||||
ParticipantResult,
|
||||
|
|
@ -240,7 +239,6 @@ export function normalizeIds(data: Database): Database {
|
|||
group: makeNormalizedIdMapping(data.group),
|
||||
round: makeNormalizedIdMapping(data.round),
|
||||
match: makeNormalizedIdMapping(data.match),
|
||||
match_game: makeNormalizedIdMapping(data.match_game),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -272,14 +270,6 @@ export function normalizeIds(data: Database): Database {
|
|||
opponent1: normalizeParticipant(value.opponent1, mappings.participant),
|
||||
opponent2: normalizeParticipant(value.opponent2, mappings.participant),
|
||||
})),
|
||||
match_game: data.match_game.map((value) => ({
|
||||
...value,
|
||||
id: mappings.match_game[value.id],
|
||||
stage_id: mappings.stage[value.stage_id],
|
||||
parent_id: mappings.match[value.parent_id],
|
||||
opponent1: normalizeParticipant(value.opponent1, mappings.participant),
|
||||
opponent2: normalizeParticipant(value.opponent2, mappings.participant),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -566,18 +556,10 @@ export function getMatchResult(match: MatchResults): Side | null {
|
|||
|
||||
let winner: Side | null = null;
|
||||
|
||||
if (
|
||||
match.opponent1?.result === "win" ||
|
||||
match.opponent2 === null ||
|
||||
match.opponent2.forfeit
|
||||
)
|
||||
if (match.opponent1?.result === "win" || match.opponent2 === null)
|
||||
winner = "opponent1";
|
||||
|
||||
if (
|
||||
match.opponent2?.result === "win" ||
|
||||
match.opponent1 === null ||
|
||||
match.opponent1.forfeit
|
||||
) {
|
||||
if (match.opponent2?.result === "win" || match.opponent1 === null) {
|
||||
if (winner !== null) throw Error("There are two winners.");
|
||||
winner = "opponent2";
|
||||
}
|
||||
|
|
@ -654,25 +636,7 @@ export function isMatchStarted(match: DeepPartial<MatchResults>): boolean {
|
|||
* @param match Partial match results.
|
||||
*/
|
||||
export function isMatchCompleted(match: DeepPartial<MatchResults>): boolean {
|
||||
return (
|
||||
isMatchByeCompleted(match) ||
|
||||
isMatchForfeitCompleted(match) ||
|
||||
isMatchResultCompleted(match)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a match is completed because of a forfeit.
|
||||
*
|
||||
* @param match Partial match results.
|
||||
*/
|
||||
export function isMatchForfeitCompleted(
|
||||
match: DeepPartial<MatchResults>,
|
||||
): boolean {
|
||||
return (
|
||||
match.opponent1?.forfeit !== undefined ||
|
||||
match.opponent2?.forfeit !== undefined
|
||||
);
|
||||
return isMatchByeCompleted(match) || isMatchResultCompleted(match);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -832,12 +796,12 @@ export function setMatchResults(
|
|||
|
||||
if (completed && currentlyCompleted) {
|
||||
// Ensure everything is good.
|
||||
setCompleted(stored, match, inRoundRobin);
|
||||
setCompleted(stored, match);
|
||||
return { statusChanged: false, resultChanged: true };
|
||||
}
|
||||
|
||||
if (completed && !currentlyCompleted) {
|
||||
setCompleted(stored, match, inRoundRobin);
|
||||
setCompleted(stored, match);
|
||||
return { statusChanged: true, resultChanged: true };
|
||||
}
|
||||
|
||||
|
|
@ -856,12 +820,10 @@ export function setMatchResults(
|
|||
*/
|
||||
export function resetMatchResults(stored: MatchResults): void {
|
||||
if (stored.opponent1) {
|
||||
stored.opponent1.forfeit = undefined;
|
||||
stored.opponent1.result = undefined;
|
||||
}
|
||||
|
||||
if (stored.opponent2) {
|
||||
stored.opponent2.forfeit = undefined;
|
||||
stored.opponent2.result = undefined;
|
||||
}
|
||||
|
||||
|
|
@ -896,7 +858,7 @@ export function setExtraFields(
|
|||
});
|
||||
};
|
||||
|
||||
const ignoredKeys: Array<keyof (Match & MatchGame)> = [
|
||||
const ignoredKeys: Array<keyof Match> = [
|
||||
"id",
|
||||
"number",
|
||||
"stage_id",
|
||||
|
|
@ -905,8 +867,6 @@ export function setExtraFields(
|
|||
"status",
|
||||
"opponent1",
|
||||
"opponent2",
|
||||
"child_count",
|
||||
"parent_id",
|
||||
];
|
||||
|
||||
const ignoredOpponentKeys: Array<keyof ParticipantResult> = [
|
||||
|
|
@ -1209,24 +1169,20 @@ export function setScores(
|
|||
*
|
||||
* @param stored A reference to what will be updated in the storage.
|
||||
* @param match Input of the update.
|
||||
* @param inRoundRobin Indicates whether the match is in a round-robin stage.
|
||||
*/
|
||||
export function setCompleted(
|
||||
stored: MatchResults,
|
||||
match: DeepPartial<MatchResults>,
|
||||
inRoundRobin: boolean,
|
||||
): void {
|
||||
stored.status = Status.Completed;
|
||||
|
||||
setResults(stored, match, "win", "loss", inRoundRobin);
|
||||
setResults(stored, match, "loss", "win", inRoundRobin);
|
||||
setResults(stored, match, "draw", "draw", inRoundRobin);
|
||||
setResults(stored, match, "win", "loss");
|
||||
setResults(stored, match, "loss", "win");
|
||||
setResults(stored, match, "draw", "draw");
|
||||
|
||||
if (stored.opponent1 && !stored.opponent2) stored.opponent1.result = "win"; // Win against opponent 2 BYE.
|
||||
|
||||
if (!stored.opponent1 && stored.opponent2) stored.opponent2.result = "win"; // Win against opponent 1 BYE.
|
||||
|
||||
setForfeits(stored, match);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1238,14 +1194,12 @@ export function setCompleted(
|
|||
* @param match Input of the update.
|
||||
* @param check A result to check in each opponent.
|
||||
* @param change A result to set in each other opponent if `check` is correct.
|
||||
* @param inRoundRobin Indicates whether the match is in a round-robin stage.
|
||||
*/
|
||||
export function setResults(
|
||||
stored: MatchResults,
|
||||
match: DeepPartial<MatchResults>,
|
||||
check: Result,
|
||||
change: Result,
|
||||
inRoundRobin: boolean,
|
||||
): void {
|
||||
if (match.opponent1 && match.opponent2) {
|
||||
if (match.opponent1.result === "win" && match.opponent2.result === "win")
|
||||
|
|
@ -1253,13 +1207,6 @@ export function setResults(
|
|||
|
||||
if (match.opponent1.result === "loss" && match.opponent2.result === "loss")
|
||||
throw Error("There are two losers.");
|
||||
|
||||
if (
|
||||
!inRoundRobin &&
|
||||
match.opponent1.forfeit === true &&
|
||||
match.opponent2.forfeit === true
|
||||
)
|
||||
throw Error("There are two forfeits.");
|
||||
}
|
||||
|
||||
if (match.opponent1?.result === check) {
|
||||
|
|
@ -1279,40 +1226,6 @@ export function setResults(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets forfeits for each opponent (if needed).
|
||||
*
|
||||
* @param stored A reference to what will be updated in the storage.
|
||||
* @param match Input of the update.
|
||||
*/
|
||||
export function setForfeits(
|
||||
stored: MatchResults,
|
||||
match: DeepPartial<MatchResults>,
|
||||
): void {
|
||||
if (match.opponent1?.forfeit === true && match.opponent2?.forfeit === true) {
|
||||
if (stored.opponent1) stored.opponent1.forfeit = true;
|
||||
if (stored.opponent2) stored.opponent2.forfeit = true;
|
||||
|
||||
// Don't set any result (win/draw/loss) with a double forfeit
|
||||
// so that it doesn't count any point in the ranking.
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.opponent1?.forfeit === true) {
|
||||
if (stored.opponent1) stored.opponent1.forfeit = true;
|
||||
|
||||
if (stored.opponent2) stored.opponent2.result = "win";
|
||||
else stored.opponent2 = { id: null, result: "win" };
|
||||
}
|
||||
|
||||
if (match.opponent2?.forfeit === true) {
|
||||
if (stored.opponent2) stored.opponent2.forfeit = true;
|
||||
|
||||
if (stored.opponent1) stored.opponent1.result = "win";
|
||||
else stored.opponent1 = { id: null, result: "win" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if a seeding is filled with participants' IDs.
|
||||
*
|
||||
|
|
@ -1547,50 +1460,6 @@ export function transitionToMinor(
|
|||
return currentDuels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the parent match to a completed status if all its child games are completed.
|
||||
*
|
||||
* @param parent The partial parent match to update.
|
||||
* @param childCount Child count of this parent match.
|
||||
* @param inRoundRobin Indicates whether the parent match is in a round-robin stage.
|
||||
*/
|
||||
export function setParentMatchCompleted(
|
||||
parent: Pick<MatchResults, "opponent1" | "opponent2">,
|
||||
childCount: number,
|
||||
inRoundRobin: boolean,
|
||||
): void {
|
||||
if (
|
||||
parent.opponent1?.score === undefined ||
|
||||
parent.opponent2?.score === undefined
|
||||
)
|
||||
throw Error("Either opponent1, opponent2 or their scores are falsy.");
|
||||
|
||||
const minToWin = minScoreToWinBestOfX(childCount);
|
||||
|
||||
if (parent.opponent1.score >= minToWin) {
|
||||
parent.opponent1.result = "win";
|
||||
return;
|
||||
}
|
||||
|
||||
if (parent.opponent2.score >= minToWin) {
|
||||
parent.opponent2.result = "win";
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
parent.opponent1.score === parent.opponent2.score &&
|
||||
parent.opponent1.score + parent.opponent2.score > childCount - 1
|
||||
) {
|
||||
if (inRoundRobin) {
|
||||
parent.opponent1.result = "draw";
|
||||
parent.opponent2.result = "draw";
|
||||
return;
|
||||
}
|
||||
|
||||
throw Error("Match games result in a tie for the parent match.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parent match results based on its child games scores.
|
||||
*
|
||||
|
|
@ -1652,26 +1521,6 @@ export function getUpdatedMatchResults<T extends MatchResults>(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the score of a parent match based on its child games.
|
||||
*
|
||||
* @param games The child games to process.
|
||||
*/
|
||||
export function getChildGamesResults(games: MatchGame[]): Scores {
|
||||
const scores = {
|
||||
opponent1: 0,
|
||||
opponent2: 0,
|
||||
};
|
||||
|
||||
for (const game of games) {
|
||||
const result = getMatchResult(game);
|
||||
if (result === "opponent1") scores.opponent1++;
|
||||
else if (result === "opponent2") scores.opponent2++;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default list of seeds for a round's matches.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Reset } from "./reset";
|
|||
import * as helpers from "./helpers";
|
||||
|
||||
/**
|
||||
* A class to handle tournament management at those levels: `stage`, `group`, `round`, `match` and `match_game`.
|
||||
* A class to handle tournament management at those levels: `stage`, `group`, `round` and `match`.
|
||||
*/
|
||||
export class BracketsManager {
|
||||
public storage: Storage;
|
||||
|
|
@ -102,11 +102,6 @@ export class BracketsManager {
|
|||
throw Error("Could not empty the match table.");
|
||||
if (!this.storage.insert("match", data.match))
|
||||
throw Error("Could not import matches.");
|
||||
|
||||
if (!this.storage.delete("match_game"))
|
||||
throw Error("Could not empty the match_game table.");
|
||||
if (!this.storage.insert("match_game", data.match_game))
|
||||
throw Error("Could not import match games.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,15 +123,12 @@ export class BracketsManager {
|
|||
const matches = this.storage.select("match");
|
||||
if (!matches) throw Error("Error getting matches.");
|
||||
|
||||
const matchGames = this.get.matchGames(matches);
|
||||
|
||||
return {
|
||||
participant: participants,
|
||||
stage: stages,
|
||||
group: groups,
|
||||
round: rounds,
|
||||
match: matches,
|
||||
match_game: matchGames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,22 +14,6 @@ export class Reset extends BaseUpdater {
|
|||
const stored = this.storage.select("match", matchId);
|
||||
if (!stored) throw Error("Match not found.");
|
||||
|
||||
// The user can handle forfeits with matches which have child games in two possible ways:
|
||||
//
|
||||
// 1. Set forfeits for the parent match directly.
|
||||
// --> The child games will never be updated: not locked, not finished, without forfeit. They will just be ignored and never be played.
|
||||
// --> To reset the forfeits, the user has to reset the parent match, with `reset.matchResults()`.
|
||||
// --> `reset.matchResults()` will be usable **only** to reset the forfeit of the parent match. Otherwise it will throw the error below.
|
||||
//
|
||||
// 2. Set forfeits for each child game.
|
||||
// --> The parent match won't automatically have a forfeit, but will be updated with a computed score according to the forfeited match games.
|
||||
// --> To reset the forfeits, the user has to reset each child game on its own, with `reset.matchGameResults()`.
|
||||
// --> `reset.matchResults()` will throw the error below in all cases.
|
||||
if (!helpers.isMatchForfeitCompleted(stored) && stored.child_count > 0)
|
||||
throw Error(
|
||||
"The parent match is controlled by its child games and its result cannot be reset.",
|
||||
);
|
||||
|
||||
const stage = this.storage.select("stage", stored.stage_id);
|
||||
if (!stage) throw Error("Stage not found.");
|
||||
|
||||
|
|
@ -65,28 +49,6 @@ export class Reset extends BaseUpdater {
|
|||
this.updateRelatedMatches(stored, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the results of a match game.
|
||||
*
|
||||
* @param gameId ID of the match game.
|
||||
*/
|
||||
public matchGameResults(gameId: number): void {
|
||||
const stored = this.storage.select("match_game", gameId);
|
||||
if (!stored) throw Error("Match game not found.");
|
||||
|
||||
const stage = this.storage.select("stage", stored.stage_id);
|
||||
if (!stage) throw Error("Stage not found.");
|
||||
|
||||
const inRoundRobin = helpers.isRoundRobin(stage);
|
||||
|
||||
helpers.resetMatchResults(stored);
|
||||
|
||||
if (!this.storage.update("match_game", stored.id, stored))
|
||||
throw Error("Could not update the match game.");
|
||||
|
||||
this.updateParentMatch(stored.parent_id, inRoundRobin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the seeding of a stage.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -127,53 +127,5 @@ ExtraFields("Extra fields when updating a match", () => {
|
|||
});
|
||||
});
|
||||
|
||||
ExtraFields("Extra fields when updating a match game", () => {
|
||||
manager.create({
|
||||
name: "Amateur",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
matchesChildCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({
|
||||
id: 0,
|
||||
// @ts-expect-error incomplete types
|
||||
weather: "rainy", // Extra field.
|
||||
opponent1: {
|
||||
score: 3,
|
||||
result: "win",
|
||||
},
|
||||
opponent2: {
|
||||
score: 1,
|
||||
result: "loss",
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({
|
||||
id: 1,
|
||||
opponent1: {
|
||||
score: 3,
|
||||
result: "win",
|
||||
// @ts-expect-error incomplete types
|
||||
foo: 42, // Extra field.
|
||||
},
|
||||
opponent2: {
|
||||
score: 1,
|
||||
result: "loss",
|
||||
// @ts-expect-error incomplete types
|
||||
info: { replacements: [1, 2] }, // Extra field.
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 0).weather, "rainy");
|
||||
assert.equal(storage.select<any>("match_game", 1).opponent1.foo, 42);
|
||||
assert.equal(storage.select<any>("match_game", 1).opponent2.info, {
|
||||
replacements: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
CustomSeeding.run();
|
||||
ExtraFields.run();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ DeleteStage("should delete a stage and all its linked data", () => {
|
|||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.delete.stage(0);
|
||||
|
|
@ -27,13 +26,11 @@ DeleteStage("should delete a stage and all its linked data", () => {
|
|||
const groups = storage.select("group")!;
|
||||
const rounds = storage.select("round")!;
|
||||
const matches = storage.select<any>("match")!;
|
||||
const games = manager.get.matchGames(matches);
|
||||
|
||||
assert.equal(stages.length, 0);
|
||||
assert.equal(groups.length, 0);
|
||||
assert.equal(rounds.length, 0);
|
||||
assert.equal(matches.length, 0);
|
||||
assert.equal(games.length, 0);
|
||||
});
|
||||
|
||||
DeleteStage("should delete one stage and only its linked data", () => {
|
||||
|
|
@ -42,7 +39,6 @@ DeleteStage("should delete one stage and only its linked data", () => {
|
|||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.create({
|
||||
|
|
@ -50,7 +46,6 @@ DeleteStage("should delete one stage and only its linked data", () => {
|
|||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.delete.stage(0);
|
||||
|
|
@ -59,20 +54,17 @@ DeleteStage("should delete one stage and only its linked data", () => {
|
|||
const groups = storage.select<any>("group")!;
|
||||
const rounds = storage.select<any>("round")!;
|
||||
const matches = storage.select<any>("match")!;
|
||||
const games = manager.get.matchGames(matches);
|
||||
|
||||
assert.equal(stages.length, 1);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(rounds.length, 2);
|
||||
assert.equal(matches.length, 3);
|
||||
assert.equal(games.length, 6);
|
||||
|
||||
// Remaining data
|
||||
assert.equal(stages[0].id, 1);
|
||||
assert.equal(groups[0].id, 1);
|
||||
assert.equal(rounds[0].id, 2);
|
||||
assert.equal(matches[0].id, 3);
|
||||
assert.equal(games[0].id, 6);
|
||||
});
|
||||
|
||||
DeleteStage("should delete all stages of the tournament", () => {
|
||||
|
|
@ -81,7 +73,6 @@ DeleteStage("should delete all stages of the tournament", () => {
|
|||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.create({
|
||||
|
|
@ -89,7 +80,6 @@ DeleteStage("should delete all stages of the tournament", () => {
|
|||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.delete.tournament(0);
|
||||
|
|
@ -98,13 +88,11 @@ DeleteStage("should delete all stages of the tournament", () => {
|
|||
const groups = storage.select("group")!;
|
||||
const rounds = storage.select("round")!;
|
||||
const matches = storage.select<any>("match")!;
|
||||
const games = manager.get.matchGames(matches);
|
||||
|
||||
assert.equal(stages.length, 0);
|
||||
assert.equal(groups.length, 0);
|
||||
assert.equal(rounds.length, 0);
|
||||
assert.equal(matches.length, 0);
|
||||
assert.equal(games.length, 0);
|
||||
});
|
||||
|
||||
DeleteStage.run();
|
||||
|
|
|
|||
|
|
@ -256,69 +256,6 @@ SpecialCases(
|
|||
},
|
||||
);
|
||||
|
||||
const UpdateMatchChildCount = suite("Update match child count");
|
||||
|
||||
UpdateMatchChildCount.before.each(() => {
|
||||
storage.reset();
|
||||
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: [
|
||||
"Team 1",
|
||||
"Team 2",
|
||||
"Team 3",
|
||||
"Team 4",
|
||||
"Team 5",
|
||||
"Team 6",
|
||||
"Team 7",
|
||||
"Team 8",
|
||||
],
|
||||
settings: { seedOrdering: ["natural"], matchesChildCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
UpdateMatchChildCount("should change match child count at match level", () => {
|
||||
manager.update.matchChildCount("match", 0, 3);
|
||||
assert.equal(storage.select<any>("match", 0).child_count, 3);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 6 + 3);
|
||||
});
|
||||
|
||||
UpdateMatchChildCount("should remove all child games of the match", () => {
|
||||
manager.update.matchChildCount("match", 0, 3); // Bo3
|
||||
manager.update.matchChildCount("match", 0, 0); // No child games.
|
||||
assert.equal(storage.select<any>("match", 0).child_count, 0);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 6);
|
||||
});
|
||||
|
||||
UpdateMatchChildCount("should change match child count at round level", () => {
|
||||
manager.update.matchChildCount("round", 2, 3); // Round of id 2 in Bo3
|
||||
assert.equal(storage.select<any>("match_game")!.length, 6 + 3);
|
||||
|
||||
manager.update.matchChildCount("round", 1, 2); // Round of id 1 in Bo2
|
||||
assert.equal(storage.select<any>("match_game")!.length, 4 + 4 + 3);
|
||||
|
||||
manager.update.matchChildCount("round", 0, 0); // Round of id 0 in Bo0 (normal matches without games)
|
||||
assert.equal(storage.select<any>("match_game")!.length, 0 + 4 + 3);
|
||||
});
|
||||
|
||||
UpdateMatchChildCount("should change match child count at group level", () => {
|
||||
manager.update.matchChildCount("group", 0, 4);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 7 * 4);
|
||||
|
||||
manager.update.matchChildCount("group", 0, 2);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 7 * 2);
|
||||
});
|
||||
|
||||
UpdateMatchChildCount("should change match child count at stage level", () => {
|
||||
manager.update.matchChildCount("stage", 0, 4);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 7 * 4);
|
||||
|
||||
manager.update.matchChildCount("stage", 0, 2);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 7 * 2);
|
||||
});
|
||||
|
||||
const SeedingAndOrderingInElimination = suite(
|
||||
"Seeding and ordering in elimination",
|
||||
);
|
||||
|
|
@ -481,289 +418,6 @@ SeedingAndOrderingInElimination(
|
|||
},
|
||||
);
|
||||
|
||||
const BestOfSeriesMatchesCompletion = suite(
|
||||
"Best-Of series matches completion",
|
||||
);
|
||||
|
||||
BestOfSeriesMatchesCompletion.before.each(() => {
|
||||
storage.reset();
|
||||
});
|
||||
|
||||
BestOfSeriesMatchesCompletion("should end Bo1 matches", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
matchesChildCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
|
||||
const match = storage.select<any>("match", 0);
|
||||
assert.equal(match.opponent1.score, 1);
|
||||
assert.equal(match.opponent2.score, 0);
|
||||
assert.equal(match.opponent1.result, "win");
|
||||
});
|
||||
|
||||
BestOfSeriesMatchesCompletion(
|
||||
"should end Bo2 matches in round-robin stage",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "round_robin",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
matchesChildCount: 2, // Bo2
|
||||
groupCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 1, opponent2: { result: "win" } });
|
||||
|
||||
const match = storage.select<any>("match", 0);
|
||||
assert.equal(match.opponent1.score, 1);
|
||||
assert.equal(match.opponent2.score, 1);
|
||||
assert.equal(match.opponent1.result, "draw");
|
||||
assert.equal(match.opponent2.result, "draw");
|
||||
},
|
||||
);
|
||||
|
||||
BestOfSeriesMatchesCompletion(
|
||||
"should throw if a BoX match has a tie in an elimination stage",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
matchesChildCount: 2, // Bo2
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
manager.update.matchGame({
|
||||
id: 1,
|
||||
opponent2: { result: "win" },
|
||||
}),
|
||||
"Match games result in a tie for the parent match.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BestOfSeriesMatchesCompletion("should end Bo3 matches", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
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" },
|
||||
});
|
||||
|
||||
const firstMatch = storage.select<any>("match", 0);
|
||||
assert.equal(firstMatch.opponent1.score, 2);
|
||||
assert.equal(firstMatch.opponent2.score, 0);
|
||||
assert.equal(firstMatch.opponent1.result, "win");
|
||||
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 1,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 2,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 3,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
|
||||
const secondMatch = storage.select<any>("match", 1);
|
||||
assert.equal(secondMatch.opponent1.score, 2);
|
||||
assert.equal(secondMatch.opponent2.score, 1);
|
||||
assert.equal(secondMatch.opponent1.result, "win");
|
||||
});
|
||||
|
||||
BestOfSeriesMatchesCompletion(
|
||||
"should let the last match be played even if not necessary",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
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" },
|
||||
});
|
||||
|
||||
let match = storage.select<any>("match", 0);
|
||||
assert.equal(match.opponent1.score, 2);
|
||||
assert.equal(match.opponent2.score, 0);
|
||||
assert.equal(match.opponent1.result, "win");
|
||||
|
||||
manager.update.matchGame({
|
||||
parent_id: 0,
|
||||
number: 3,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
|
||||
match = storage.select<any>("match", 0);
|
||||
assert.equal(match.opponent1.score, 2);
|
||||
assert.equal(match.opponent2.score, 1);
|
||||
assert.equal(match.opponent1.result, "win");
|
||||
},
|
||||
);
|
||||
|
||||
BestOfSeriesMatchesCompletion("should end Bo5 matches", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
matchesChildCount: 5,
|
||||
},
|
||||
});
|
||||
|
||||
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: 0,
|
||||
number: 3,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
|
||||
const firstMatch = storage.select<any>("match", 0);
|
||||
assert.equal(firstMatch.opponent1.score, 3);
|
||||
assert.equal(firstMatch.opponent2.score, 0);
|
||||
assert.equal(firstMatch.opponent1.result, "win");
|
||||
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 1,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 2,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 3,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 4,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
|
||||
const secondMatch = storage.select<any>("match", 1);
|
||||
assert.equal(secondMatch.opponent1.score, 3);
|
||||
assert.equal(secondMatch.opponent2.score, 1);
|
||||
assert.equal(secondMatch.opponent1.result, "win");
|
||||
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 1,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 2,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 3,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 4,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 5,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
|
||||
const thirdMatch = storage.select<any>("match", 2);
|
||||
assert.equal(thirdMatch.opponent1.score, 3);
|
||||
assert.equal(thirdMatch.opponent2.score, 2);
|
||||
assert.equal(thirdMatch.opponent1.result, "win");
|
||||
});
|
||||
|
||||
BestOfSeriesMatchesCompletion(
|
||||
"should handle match auto-win against a BYE after a BoX series",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
matchesChildCount: 3,
|
||||
size: 8,
|
||||
consolationFinal: true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
|
||||
assert.equal(storage.select<any>("match", 4).opponent1.result, "win");
|
||||
assert.equal(storage.select<any>("match", 6).opponent1.result, "win");
|
||||
},
|
||||
);
|
||||
|
||||
const ResetMatchAndMatchGames = suite("Reset match and match games");
|
||||
|
||||
ResetMatchAndMatchGames.before.each(() => {
|
||||
|
|
@ -852,34 +506,6 @@ ResetMatchAndMatchGames(
|
|||
},
|
||||
);
|
||||
|
||||
ResetMatchAndMatchGames("should reset results of a match game", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
matchesChildCount: 3,
|
||||
consolationFinal: true,
|
||||
size: 8,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
|
||||
assert.equal(storage.select<any>("match", 4).opponent1.result, "win");
|
||||
assert.equal(storage.select<any>("match", 6).opponent1.result, "win");
|
||||
assert.equal(storage.select<any>("match", 7).opponent1, null); // BYE in consolation final.
|
||||
|
||||
manager.reset.matchGameResults(1);
|
||||
|
||||
assert.equal(storage.select<any>("match", 4).opponent1.result, undefined);
|
||||
assert.equal(storage.select<any>("match", 6).opponent1.result, undefined);
|
||||
assert.equal(storage.select<any>("match", 7).opponent1, null); // Still BYE in consolation final.
|
||||
});
|
||||
|
||||
const ImportExport = suite("Import / export");
|
||||
|
||||
ImportExport.before.each(() => {
|
||||
|
|
@ -894,7 +520,6 @@ ImportExport("should import data in the storage", () => {
|
|||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
matchesChildCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -937,7 +562,6 @@ ImportExport("should import data in the storage with normalized IDs", () => {
|
|||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
groupCount: 1,
|
||||
matchesChildCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -948,7 +572,6 @@ ImportExport("should import data in the storage with normalized IDs", () => {
|
|||
seeding: ["Team 5", "Team 6", "Team 7", "Team 8"],
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
matchesChildCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -976,16 +599,6 @@ ImportExport("should import data in the storage with normalized IDs", () => {
|
|||
opponent2: { id: 6, position: 2 },
|
||||
number: 1,
|
||||
status: 2,
|
||||
child_count: 1,
|
||||
});
|
||||
assert.equal(initialData.match_game[0], {
|
||||
id: 6,
|
||||
number: 1,
|
||||
stage_id: 1,
|
||||
parent_id: 6,
|
||||
status: 2,
|
||||
opponent1: { id: 5 },
|
||||
opponent2: { id: 6 },
|
||||
});
|
||||
|
||||
manager.import(initialData, true);
|
||||
|
|
@ -1014,16 +627,6 @@ ImportExport("should import data in the storage with normalized IDs", () => {
|
|||
opponent2: { id: 5, position: 2 },
|
||||
number: 1,
|
||||
status: 2,
|
||||
child_count: 1,
|
||||
});
|
||||
assert.equal(data.match_game[0], {
|
||||
id: 0,
|
||||
number: 1,
|
||||
stage_id: 0,
|
||||
parent_id: 0,
|
||||
status: 2,
|
||||
opponent1: { id: 4 },
|
||||
opponent2: { id: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1035,20 +638,12 @@ ImportExport("should export data from the storage", () => {
|
|||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
matchesChildCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const data = manager.export();
|
||||
|
||||
for (const key of [
|
||||
"participant",
|
||||
"stage",
|
||||
"group",
|
||||
"round",
|
||||
"match",
|
||||
"match_game",
|
||||
]) {
|
||||
for (const key of ["participant", "stage", "group", "round", "match"]) {
|
||||
assert.ok(Object.keys(data).includes(key));
|
||||
}
|
||||
|
||||
|
|
@ -1057,14 +652,11 @@ ImportExport("should export data from the storage", () => {
|
|||
assert.equal(storage.select<any>("group"), data.group);
|
||||
assert.equal(storage.select<any>("round"), data.round);
|
||||
assert.equal(storage.select<any>("match"), data.match);
|
||||
assert.equal(storage.select<any>("match_game"), data.match_game);
|
||||
});
|
||||
|
||||
BYEHandling.run();
|
||||
PositionChecks.run();
|
||||
SpecialCases.run();
|
||||
UpdateMatchChildCount.run();
|
||||
SeedingAndOrderingInElimination.run();
|
||||
BestOfSeriesMatchesCompletion.run();
|
||||
ResetMatchAndMatchGames.run();
|
||||
ImportExport.run();
|
||||
|
|
|
|||
|
|
@ -6,50 +6,6 @@ import * as assert from "uvu/assert";
|
|||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
||||
const GetChildGames = suite("Get child games");
|
||||
|
||||
GetChildGames.before.each(() => {
|
||||
storage.reset();
|
||||
});
|
||||
|
||||
GetChildGames("should get child games of a list of matches", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
const matches = storage.select<any>("match", { round_id: 0 })!;
|
||||
const games = manager.get.matchGames(matches);
|
||||
|
||||
assert.equal(matches.length, 2);
|
||||
assert.equal(games.length, 4);
|
||||
assert.equal(games[2].parent_id, 1);
|
||||
});
|
||||
|
||||
GetChildGames(
|
||||
"should get child games of a list of matches with some which do not have child games",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 2 },
|
||||
});
|
||||
|
||||
manager.update.matchChildCount("match", 1, 0); // Remove child games from match id 1.
|
||||
|
||||
const matches = storage.select<any>("match", { round_id: 0 })!;
|
||||
const games = manager.get.matchGames(matches);
|
||||
|
||||
assert.equal(matches.length, 2);
|
||||
assert.equal(games.length, 2); // Only two child games.
|
||||
},
|
||||
);
|
||||
|
||||
const GetFinalStandings = suite("Get final standings");
|
||||
|
||||
GetFinalStandings.before.each(() => {
|
||||
|
|
@ -362,6 +318,5 @@ GetSeeding("should get the seeding with BYEs", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
GetChildGames.run();
|
||||
GetFinalStandings.run();
|
||||
GetSeeding.run();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { InMemoryDatabase } from "~/modules/brackets-memory-db";
|
|||
import { BracketsManager } from "../manager";
|
||||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import { Status } from "~/db/types";
|
||||
|
||||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
|
@ -259,21 +258,6 @@ UpdateRoundRobinScores.before.each(() => {
|
|||
});
|
||||
});
|
||||
|
||||
UpdateRoundRobinScores("should set two forfeits for the match", () => {
|
||||
manager.update.match({
|
||||
id: 0,
|
||||
opponent1: { forfeit: true },
|
||||
opponent2: { forfeit: true },
|
||||
});
|
||||
|
||||
const after = storage.select<any>("match", 0);
|
||||
assert.equal(after.status, Status.Completed);
|
||||
assert.equal(after.opponent1.forfeit, true);
|
||||
assert.equal(after.opponent2.forfeit, true);
|
||||
assert.equal(after.opponent1.result, undefined);
|
||||
assert.equal(after.opponent2.result, undefined);
|
||||
});
|
||||
|
||||
const ExampleUseCase = suite("Example use-case");
|
||||
|
||||
// Example taken from here:
|
||||
|
|
|
|||
|
|
@ -149,13 +149,12 @@ CreateSingleEliminationStage(
|
|||
"Team 7",
|
||||
"Team 8",
|
||||
],
|
||||
settings: { seedOrdering: ["natural"], matchesChildCount: 3 },
|
||||
settings: { seedOrdering: ["natural"] },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("group")!.length, 1);
|
||||
assert.equal(storage.select<any>("round")!.length, 3);
|
||||
assert.equal(storage.select<any>("match")!.length, 7);
|
||||
assert.equal(storage.select<any>("match_game")!.length, 7 * 3);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,37 +128,6 @@ UpdateMatches("should update the status of the next match", () => {
|
|||
assert.equal(storage.select<any>("match", 8).status, Status.Ready);
|
||||
});
|
||||
|
||||
UpdateMatches("should end the match by only setting a forfeit", () => {
|
||||
const before = storage.select<any>("match", 2);
|
||||
assert.not.ok(before.opponent1.result);
|
||||
|
||||
manager.update.match({
|
||||
id: 2,
|
||||
opponent1: { forfeit: true },
|
||||
});
|
||||
|
||||
const after = storage.select<any>("match", 2);
|
||||
assert.equal(after.status, Status.Completed);
|
||||
assert.equal(after.opponent1.forfeit, true);
|
||||
assert.equal(after.opponent1.result, undefined);
|
||||
assert.equal(after.opponent2.result, "win");
|
||||
});
|
||||
|
||||
UpdateMatches("should remove forfeit from a match", () => {
|
||||
manager.update.match({
|
||||
id: 2,
|
||||
opponent1: { forfeit: true },
|
||||
});
|
||||
|
||||
manager.reset.matchResults(2);
|
||||
|
||||
const after = storage.select<any>("match", 2);
|
||||
assert.equal(after.status, Status.Ready);
|
||||
assert.not.ok(after.opponent1.forfeit);
|
||||
assert.not.ok(after.opponent1.result);
|
||||
assert.not.ok(after.opponent2.result);
|
||||
});
|
||||
|
||||
UpdateMatches("should end the match by setting winner and loser", () => {
|
||||
manager.update.match({
|
||||
id: 0,
|
||||
|
|
@ -263,41 +232,6 @@ UpdateMatches("should throw if two winners", () => {
|
|||
);
|
||||
});
|
||||
|
||||
UpdateMatches("should throw if two forfeits", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
manager.update.match({
|
||||
id: 3,
|
||||
opponent1: { forfeit: true },
|
||||
opponent2: { forfeit: true },
|
||||
}),
|
||||
"There are two forfeits.",
|
||||
);
|
||||
});
|
||||
|
||||
UpdateMatches(
|
||||
"should throw if one forfeit then the other without resetting the match between",
|
||||
() => {
|
||||
manager.update.match({
|
||||
id: 2,
|
||||
opponent1: { forfeit: true },
|
||||
});
|
||||
|
||||
const after = storage.select<any>("match", 2);
|
||||
assert.equal(after.opponent1.forfeit, true);
|
||||
assert.not.ok(after.opponent2.forfeit);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
manager.update.match({
|
||||
id: 2,
|
||||
opponent2: { forfeit: true },
|
||||
}),
|
||||
"Didn't throw when updating a match with second forfeit.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const GiveOpponentIds = suite("Give opponent IDs when updating");
|
||||
|
||||
GiveOpponentIds.before.each(() => {
|
||||
|
|
@ -396,273 +330,6 @@ LockedMatches(
|
|||
},
|
||||
);
|
||||
|
||||
const UpdateMatchGames = suite("Update match games");
|
||||
|
||||
UpdateMatchGames.before.each(() => {
|
||||
storage.reset();
|
||||
});
|
||||
|
||||
UpdateMatchGames(
|
||||
"should update child games status based on the parent match status",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
size: 4,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchChildCount("stage", 0, 2); // Set Bo2 for all the stage.
|
||||
assert.equal(
|
||||
storage.select<any>("match", 0).status,
|
||||
storage.select<any>("match_game", 0).status,
|
||||
);
|
||||
|
||||
manager.update.seeding(0, ["Team 1", "Team 2", "Team 3", "Team 4"]);
|
||||
assert.equal(
|
||||
storage.select<any>("match", 0).status,
|
||||
storage.select<any>("match_game", 0).status,
|
||||
);
|
||||
|
||||
// Semi 1
|
||||
manager.update.matchGame({
|
||||
parent_id: 0,
|
||||
number: 1,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 0,
|
||||
number: 2,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
assert.equal(storage.select<any>("match", 0).status, Status.Completed);
|
||||
assert.equal(storage.select<any>("match", 0).opponent1.score, 2);
|
||||
assert.equal(storage.select<any>("match", 0).opponent2.score, 0);
|
||||
|
||||
let finalMatchStatus = storage.select<any>("match", 2).status;
|
||||
assert.equal(finalMatchStatus, Status.Waiting);
|
||||
assert.equal(finalMatchStatus, storage.select<any>("match_game", 4).status);
|
||||
|
||||
// Semi 2
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 1,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 1,
|
||||
number: 2,
|
||||
opponent2: { result: "win" },
|
||||
});
|
||||
|
||||
finalMatchStatus = storage.select<any>("match", 2).status;
|
||||
assert.equal(finalMatchStatus, Status.Ready);
|
||||
assert.equal(finalMatchStatus, storage.select<any>("match_game", 4).status);
|
||||
|
||||
// Final
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 1,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
manager.update.matchGame({
|
||||
parent_id: 2,
|
||||
number: 2,
|
||||
opponent1: { result: "win" },
|
||||
});
|
||||
|
||||
finalMatchStatus = storage.select<any>("match", 2).status;
|
||||
assert.equal(finalMatchStatus, storage.select<any>("match_game", 4).status);
|
||||
|
||||
const semi1Status = storage.select<any>("match", 0).status;
|
||||
assert.equal(semi1Status, storage.select<any>("match_game", 0).status);
|
||||
|
||||
const semi2Status = storage.select<any>("match", 1).status;
|
||||
assert.equal(semi2Status, storage.select<any>("match_game", 2).status);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames(
|
||||
"should update parent score when match game is updated",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "With match games",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: {
|
||||
matchesChildCount: 3, // Bo3.
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
const firstChildCompleted = storage.select<any>("match", 0);
|
||||
assert.equal(firstChildCompleted.status, Status.Running);
|
||||
assert.equal(firstChildCompleted.opponent1.score, 1);
|
||||
assert.equal(firstChildCompleted.opponent2.score, 0);
|
||||
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
const secondChildCompleted = storage.select<any>("match", 0);
|
||||
assert.equal(secondChildCompleted.status, Status.Completed);
|
||||
assert.equal(secondChildCompleted.opponent1.score, 2);
|
||||
assert.equal(secondChildCompleted.opponent2.score, 0);
|
||||
|
||||
manager.reset.matchGameResults(1);
|
||||
const secondChildReset = storage.select<any>("match", 0);
|
||||
assert.equal(secondChildReset.status, Status.Running);
|
||||
assert.equal(secondChildReset.opponent1.score, 1);
|
||||
assert.equal(secondChildReset.opponent2.score, 0);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames("should throw if trying to update a locked match game", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
size: 4,
|
||||
matchesChildCount: 3, // Example with all Bo3 at creation time.
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => manager.update.matchGame({ id: 0 }),
|
||||
"The match game is locked.",
|
||||
);
|
||||
|
||||
storage.reset();
|
||||
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
settings: {
|
||||
seedOrdering: ["natural"],
|
||||
size: 4,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchChildCount("round", 0, 3); // Example with all Bo3 after creation time.
|
||||
assert.throws(
|
||||
() => manager.update.matchGame({ id: 0 }),
|
||||
"The match game is locked.",
|
||||
);
|
||||
});
|
||||
|
||||
UpdateMatchGames(
|
||||
"should propagate the winner of the parent match in the next match",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { seedOrdering: ["natural"] },
|
||||
});
|
||||
|
||||
manager.update.matchChildCount("round", 0, 3);
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 2, opponent2: { result: "win" } });
|
||||
|
||||
assert.equal(
|
||||
storage.select<any>("match", 2).opponent1.id, // Should be determined automatically.
|
||||
storage.select<any>("match", 0).opponent1.id, // Winner of the first BO3 match.
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames(
|
||||
"should select a match game with its parent match id and number",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
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" },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match", 0).opponent1.score, 2);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames(
|
||||
"should throw if trying to reset the results of a parent match",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
matchesChildCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => manager.reset.matchResults(0),
|
||||
"The parent match is controlled by its child games and its result cannot be reset.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames(
|
||||
"should reset the results of a parent match when a child game's results are reset",
|
||||
() => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
matchesChildCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 0, opponent1: { result: "win" } });
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
|
||||
manager.reset.matchGameResults(0);
|
||||
assert.equal(storage.select<any>("match", 0).status, Status.Running);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatchGames("should reset the forfeit of a parent match", () => {
|
||||
manager.create({
|
||||
name: "Example",
|
||||
tournamentId: 0,
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2"],
|
||||
settings: {
|
||||
matchesChildCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
manager.update.match({ id: 0, opponent1: { forfeit: true } });
|
||||
manager.reset.matchResults(0);
|
||||
});
|
||||
|
||||
const Seeding = suite("Seeding");
|
||||
|
||||
Seeding.before.each(() => {
|
||||
|
|
@ -1043,237 +710,7 @@ Seeding("should confirm the current seeding", () => {
|
|||
assert.equal(storage.select<any>("match", 10).opponent2, null);
|
||||
});
|
||||
|
||||
const MatchGamesStatus = suite("Match games status");
|
||||
|
||||
MatchGamesStatus.before.each(() => {
|
||||
storage.reset();
|
||||
});
|
||||
|
||||
MatchGamesStatus(
|
||||
"should have all the child games to Locked when the parent match is Locked",
|
||||
() => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 3 },
|
||||
});
|
||||
|
||||
const games = storage.select<any>("match_game", { parent_id: 2 });
|
||||
assert.equal(games![0].status, Status.Locked);
|
||||
assert.equal(games![1].status, Status.Locked);
|
||||
assert.equal(games![2].status, Status.Locked);
|
||||
},
|
||||
);
|
||||
|
||||
MatchGamesStatus("should set all the child games to Waiting", () => {
|
||||
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" },
|
||||
});
|
||||
|
||||
const games = storage.select<any>("match_game", { parent_id: 2 });
|
||||
assert.equal(games![0].status, Status.Waiting);
|
||||
assert.equal(games![1].status, Status.Waiting);
|
||||
assert.equal(games![2].status, Status.Waiting);
|
||||
});
|
||||
|
||||
MatchGamesStatus("should set all the child games to Ready", () => {
|
||||
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" },
|
||||
});
|
||||
|
||||
const games = storage.select<any>("match_game", { parent_id: 2 });
|
||||
assert.equal(games![0].status, Status.Ready);
|
||||
assert.equal(games![1].status, Status.Ready);
|
||||
assert.equal(games![2].status, Status.Ready);
|
||||
});
|
||||
|
||||
MatchGamesStatus(
|
||||
"should set the parent match to Running when one match game starts",
|
||||
() => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 3 },
|
||||
});
|
||||
|
||||
manager.update.matchGame({
|
||||
id: 1,
|
||||
opponent1: { score: 0 },
|
||||
opponent2: { score: 0 },
|
||||
});
|
||||
|
||||
const games = storage.select<any>("match_game", { parent_id: 0 });
|
||||
|
||||
// Siblings are left untouched.
|
||||
assert.equal(games![0].status, Status.Ready);
|
||||
assert.equal(games![2].status, Status.Ready);
|
||||
|
||||
assert.equal(games![1].status, Status.Running);
|
||||
assert.equal(storage.select<any>("match", 0).status, Status.Running);
|
||||
},
|
||||
);
|
||||
|
||||
MatchGamesStatus(
|
||||
"should set the child game to Completed without changing the siblings or the parent match status",
|
||||
() => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 3 },
|
||||
});
|
||||
|
||||
manager.update.matchGame({ id: 1, opponent1: { result: "win" } });
|
||||
|
||||
const games = storage.select<any>("match_game", { parent_id: 0 });
|
||||
|
||||
// Siblings and parent match are left untouched.
|
||||
assert.equal(games![0].status, Status.Ready);
|
||||
assert.equal(games![2].status, Status.Ready);
|
||||
assert.equal(storage.select<any>("match", 0).status, Status.Running);
|
||||
|
||||
assert.equal(games![1].status, Status.Completed);
|
||||
},
|
||||
);
|
||||
|
||||
MatchGamesStatus("should set the parent match to Completed", () => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "single_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 3 },
|
||||
});
|
||||
|
||||
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.Completed);
|
||||
|
||||
// Left untouched, can be played if we want.
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Ready);
|
||||
|
||||
manager.update.matchGame({ id: 2, opponent1: { result: "win" } });
|
||||
assert.equal(storage.select<any>("match", 0).status, Status.Completed);
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Completed);
|
||||
});
|
||||
|
||||
MatchGamesStatus(
|
||||
"should work with unique match games when controlled via the parent",
|
||||
() => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "double_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 1 },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Locked);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Locked);
|
||||
|
||||
manager.update.match({
|
||||
id: 0,
|
||||
opponent1: { score: 2, result: "win" },
|
||||
opponent2: { score: 1 },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Waiting);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Waiting);
|
||||
|
||||
manager.update.match({
|
||||
id: 1,
|
||||
opponent1: { score: 1 },
|
||||
opponent2: { score: 2, result: "win" },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Ready);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Ready);
|
||||
},
|
||||
);
|
||||
|
||||
MatchGamesStatus(
|
||||
"should work with unique match games when controlled via the child games",
|
||||
() => {
|
||||
manager.create({
|
||||
tournamentId: 0,
|
||||
name: "Example",
|
||||
type: "double_elimination",
|
||||
seeding: ["Team 1", "Team 2", "Team 3", "Team 4"],
|
||||
settings: { matchesChildCount: 1 },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Locked);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Locked);
|
||||
|
||||
manager.update.matchGame({
|
||||
id: 0,
|
||||
opponent1: { score: 2, result: "win" },
|
||||
opponent2: { score: 1 },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Waiting);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Waiting);
|
||||
|
||||
manager.update.matchGame({
|
||||
id: 1,
|
||||
opponent1: { score: 1 },
|
||||
opponent2: { score: 2, result: "win" },
|
||||
});
|
||||
|
||||
assert.equal(storage.select<any>("match_game", 2).status, Status.Ready);
|
||||
assert.equal(storage.select<any>("match_game", 3).status, Status.Ready);
|
||||
},
|
||||
);
|
||||
|
||||
UpdateMatches.run();
|
||||
GiveOpponentIds.run();
|
||||
LockedMatches.run();
|
||||
UpdateMatchGames.run();
|
||||
Seeding.run();
|
||||
MatchGamesStatus.run();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type {
|
||||
Group,
|
||||
Match,
|
||||
MatchGame,
|
||||
Participant,
|
||||
Round,
|
||||
SeedOrdering,
|
||||
|
|
@ -51,11 +50,6 @@ export type Side = "opponent1" | "opponent2";
|
|||
*/
|
||||
export type Scores = { opponent1: number; opponent2: number };
|
||||
|
||||
/**
|
||||
* The possible levels of data to which we can update the child games count.
|
||||
*/
|
||||
export type ChildCountLevel = "stage" | "group" | "round" | "match";
|
||||
|
||||
/**
|
||||
* Positional information about a round.
|
||||
*/
|
||||
|
|
@ -96,7 +90,6 @@ export interface DataTypes {
|
|||
group: Group;
|
||||
round: Round;
|
||||
match: Match;
|
||||
match_game: MatchGame;
|
||||
participant: Participant;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type {
|
||||
Match,
|
||||
MatchGame,
|
||||
Round,
|
||||
Seeding,
|
||||
SeedOrdering,
|
||||
|
|
@ -8,7 +7,7 @@ import type {
|
|||
import { Status } from "~/modules/brackets-model";
|
||||
import { ordering } from "./ordering";
|
||||
import { BaseUpdater } from "./base/updater";
|
||||
import type { ChildCountLevel, DeepPartial } from "./types";
|
||||
import type { DeepPartial } from "./types";
|
||||
import * as helpers from "./helpers";
|
||||
|
||||
export class Update extends BaseUpdater {
|
||||
|
|
@ -28,21 +27,6 @@ export class Update extends BaseUpdater {
|
|||
this.updateMatch(stored, match);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates partial information of a match game. Its id must be given.
|
||||
*
|
||||
* This will update the parent match accordingly.
|
||||
*
|
||||
* @param game Values to change in a match game.
|
||||
*/
|
||||
public matchGame<G extends MatchGame = MatchGame>(
|
||||
game: DeepPartial<G>,
|
||||
): void {
|
||||
const stored = this.findMatchGame(game);
|
||||
|
||||
this.updateMatchGame(stored, game);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the seed ordering of every ordered round in a stage.
|
||||
*
|
||||
|
|
@ -81,39 +65,6 @@ export class Update extends BaseUpdater {
|
|||
this.updateRoundOrdering(round, method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates child count of all matches of a given level.
|
||||
*
|
||||
* @param level The level at which to act.
|
||||
* @param id ID of the chosen level.
|
||||
* @param childCount The target child count.
|
||||
*/
|
||||
public matchChildCount(
|
||||
level: ChildCountLevel,
|
||||
id: number,
|
||||
childCount: number,
|
||||
): void {
|
||||
switch (level) {
|
||||
case "stage":
|
||||
this.updateStageMatchChildCount(id, childCount);
|
||||
break;
|
||||
case "group":
|
||||
this.updateGroupMatchChildCount(id, childCount);
|
||||
break;
|
||||
case "round":
|
||||
this.updateRoundMatchChildCount(id, childCount);
|
||||
break;
|
||||
case "match":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const match = this.storage.select("match", id);
|
||||
if (!match) throw Error("Match not found.");
|
||||
this.adjustMatchChildGames(match, childCount);
|
||||
break;
|
||||
default:
|
||||
throw Error("Unknown child count level.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the seeding of a stage.
|
||||
*
|
||||
|
|
@ -168,81 +119,6 @@ export class Update extends BaseUpdater {
|
|||
this.applyRoundOrdering(round.number, matches, positions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates child count of all matches of a stage.
|
||||
*
|
||||
* @param stageId ID of the stage.
|
||||
* @param childCount The target child count.
|
||||
*/
|
||||
private updateStageMatchChildCount(
|
||||
stageId: number,
|
||||
childCount: number,
|
||||
): void {
|
||||
if (
|
||||
!this.storage.update(
|
||||
"match",
|
||||
{ stage_id: stageId },
|
||||
{ child_count: childCount },
|
||||
)
|
||||
)
|
||||
throw Error("Could not update the match.");
|
||||
|
||||
const matches = this.storage.select("match", { stage_id: stageId });
|
||||
if (!matches) throw Error("This stage has no match.");
|
||||
|
||||
for (const match of matches) this.adjustMatchChildGames(match, childCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates child count of all matches of a group.
|
||||
*
|
||||
* @param groupId ID of the group.
|
||||
* @param childCount The target child count.
|
||||
*/
|
||||
private updateGroupMatchChildCount(
|
||||
groupId: number,
|
||||
childCount: number,
|
||||
): void {
|
||||
if (
|
||||
!this.storage.update(
|
||||
"match",
|
||||
{ group_id: groupId },
|
||||
{ child_count: childCount },
|
||||
)
|
||||
)
|
||||
throw Error("Could not update the match.");
|
||||
|
||||
const matches = this.storage.select("match", { group_id: groupId });
|
||||
if (!matches) throw Error("This group has no match.");
|
||||
|
||||
for (const match of matches) this.adjustMatchChildGames(match, childCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates child count of all matches of a round.
|
||||
*
|
||||
* @param roundId ID of the round.
|
||||
* @param childCount The target child count.
|
||||
*/
|
||||
private updateRoundMatchChildCount(
|
||||
roundId: number,
|
||||
childCount: number,
|
||||
): void {
|
||||
if (
|
||||
!this.storage.update(
|
||||
"match",
|
||||
{ round_id: roundId },
|
||||
{ child_count: childCount },
|
||||
)
|
||||
)
|
||||
throw Error("Could not update the match.");
|
||||
|
||||
const matches = this.storage.select("match", { round_id: roundId });
|
||||
if (!matches) throw Error("This round has no match.");
|
||||
|
||||
for (const match of matches) this.adjustMatchChildGames(match, childCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ordering of participants in a round's matches.
|
||||
*
|
||||
|
|
@ -267,53 +143,4 @@ export class Update extends BaseUpdater {
|
|||
throw Error("Could not update the match.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or deletes match games of a match based on a target child count.
|
||||
*
|
||||
* @param match The match of which child games need to be adjusted.
|
||||
* @param targetChildCount The target child count.
|
||||
*/
|
||||
private adjustMatchChildGames(match: Match, targetChildCount: number): void {
|
||||
const games = this.storage.select("match_game", {
|
||||
parent_id: match.id,
|
||||
});
|
||||
let childCount = games ? games.length : 0;
|
||||
|
||||
while (childCount < targetChildCount) {
|
||||
const id = this.storage.insert("match_game", {
|
||||
number: childCount + 1,
|
||||
stage_id: match.stage_id,
|
||||
parent_id: match.id,
|
||||
status: match.status,
|
||||
opponent1: { id: null },
|
||||
opponent2: { id: null },
|
||||
});
|
||||
|
||||
if (id === -1)
|
||||
throw Error("Could not adjust the match games when inserting.");
|
||||
|
||||
childCount++;
|
||||
}
|
||||
|
||||
while (childCount > targetChildCount) {
|
||||
const deleted = this.storage.delete("match_game", {
|
||||
parent_id: match.id,
|
||||
number: childCount,
|
||||
});
|
||||
|
||||
if (!deleted)
|
||||
throw Error("Could not adjust the match games when deleting.");
|
||||
|
||||
childCount--;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.storage.update("match", match.id, {
|
||||
...match,
|
||||
child_count: targetChildCount,
|
||||
})
|
||||
)
|
||||
throw Error("Could not update the match.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
group: [],
|
||||
round: [],
|
||||
match: [],
|
||||
match_game: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -46,7 +45,6 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
group: [],
|
||||
round: [],
|
||||
match: [],
|
||||
match_game: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +71,14 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
|
||||
if (!Array.isArray(values)) {
|
||||
try {
|
||||
// @ts-expect-error imported
|
||||
this.data[table].push({ id, ...values });
|
||||
// @ts-expect-error idc
|
||||
if (values.id) {
|
||||
// @ts-expect-error idc
|
||||
this.data[table][values.id] = values;
|
||||
} else {
|
||||
// @ts-expect-error imported
|
||||
this.data[table].push({ id, ...values });
|
||||
}
|
||||
} catch (error) {
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -83,8 +87,14 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
|
||||
try {
|
||||
values.forEach((object) => {
|
||||
// @ts-expect-error imported
|
||||
this.data[table].push({ id: id++, ...object });
|
||||
// @ts-expect-error idc
|
||||
if (object.id) {
|
||||
// @ts-expect-error idc
|
||||
this.data[table][object.id] = object;
|
||||
} else {
|
||||
// @ts-expect-error imported
|
||||
this.data[table].push({ id: id++, ...object });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
|
|
@ -127,7 +137,7 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
|
||||
if (typeof arg === "number") {
|
||||
// @ts-expect-error imported
|
||||
return clone(this.data[table].find((d) => d.id === arg));
|
||||
return clone(this.data[table].find((d) => d?.id === arg));
|
||||
}
|
||||
|
||||
// @ts-expect-error imported
|
||||
|
|
@ -169,6 +179,14 @@ export class InMemoryDatabase implements CrudInterface {
|
|||
value?: Partial<T>,
|
||||
): boolean {
|
||||
if (typeof arg === "number") {
|
||||
if (
|
||||
this.data[table][arg] &&
|
||||
value &&
|
||||
// @ts-expect-error idc
|
||||
this.data[table][arg].id !== value.id
|
||||
) {
|
||||
throw new Error("ID mismatch.");
|
||||
}
|
||||
try {
|
||||
// @ts-expect-error imported
|
||||
this.data[table][arg] = value;
|
||||
|
|
|
|||
|
|
@ -84,11 +84,6 @@ export interface StageSettings {
|
|||
*/
|
||||
balanceByes?: boolean;
|
||||
|
||||
/**
|
||||
* All matches of the stage will have this child count. This can later be overridden for certain groups, rounds or matches.
|
||||
*/
|
||||
matchesChildCount?: number;
|
||||
|
||||
/**
|
||||
* Number of groups in a round-robin stage.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -95,23 +95,5 @@ export interface Match extends MatchResults {
|
|||
/** The number of the match in its round. */
|
||||
number: number;
|
||||
|
||||
/** The count of match games this match has. Can be `0` if it's a simple match, or a positive number for "Best Of" matches. */
|
||||
child_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A game of a match.
|
||||
*/
|
||||
export interface MatchGame extends MatchResults {
|
||||
/** ID of the match game. */
|
||||
id: number;
|
||||
|
||||
/** ID of the parent stage. */
|
||||
stage_id: number;
|
||||
|
||||
/** ID of the parent match. */
|
||||
parent_id: number;
|
||||
|
||||
/** The number of the match game in its parent match. */
|
||||
number: number;
|
||||
lastGameFinishedAt?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ export async function getStreams() {
|
|||
const result = await cachified({
|
||||
key: `twitch-streams`,
|
||||
cache,
|
||||
// 5 minutes
|
||||
ttl: 1000 * 60 * 5,
|
||||
// 2 minutes
|
||||
ttl: 1000 * 60 * 2,
|
||||
// 10 minutes
|
||||
staleWhileRevalidate: 1000 * 60 * 5 * 2,
|
||||
async getFreshValue() {
|
||||
|
|
|
|||
|
|
@ -161,10 +161,6 @@ function Document({
|
|||
<Links />
|
||||
<ThemeHead />
|
||||
<link rel="manifest" href="/app.webmanifest" />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/brackets-viewer@1.5.1/dist/brackets-viewer.min.js"
|
||||
/>
|
||||
{data?.browserTimingHeader ? (
|
||||
<script
|
||||
type="text/javascript"
|
||||
|
|
|
|||
|
|
@ -639,6 +639,17 @@ dialog::backdrop {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.info-popover__trigger {
|
||||
border: 2px solid var(--bg-lightest);
|
||||
border-radius: 100%;
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
font-size: var(--fonts-md);
|
||||
padding: var(--s-0-5);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.articles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -174,6 +174,10 @@
|
|||
padding-block-end: var(--s-4);
|
||||
}
|
||||
|
||||
.pt-3 {
|
||||
padding-block-start: var(--s-3);
|
||||
}
|
||||
|
||||
.pt-8-forced {
|
||||
padding-block-start: var(--s-8) !important;
|
||||
}
|
||||
|
|
@ -254,6 +258,10 @@
|
|||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-inline-end: var(--s-2);
|
||||
}
|
||||
|
||||
.mr-4 {
|
||||
margin-inline-end: var(--s-4);
|
||||
}
|
||||
|
|
@ -310,6 +318,14 @@
|
|||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
|
@ -398,6 +414,10 @@
|
|||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.opaque {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 480px) {
|
||||
.mobile-hidden {
|
||||
display: inherit;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ html {
|
|||
--bg-lighter: rgb(250 250 250);
|
||||
--bg-lighter-solid: rgb(250 250 250);
|
||||
--bg-lightest: #fff;
|
||||
--bg-lightest-solid: #fff;
|
||||
--bg-light-variation: #fff;
|
||||
--bg-lighter-transparent: hsla(225deg 100% 88% / 50%);
|
||||
--bg-darker-very-transparent: rgb(187 208 219 / 29.5%);
|
||||
|
|
@ -102,6 +103,7 @@ html.dark {
|
|||
--bg-lighter-transparent: rgb(64 67 108 / 50%);
|
||||
--bg-light-variation: #a98aff30;
|
||||
--bg-lightest: rgb(169 138 255 / 30%);
|
||||
--bg-lightest-solid: #342b62;
|
||||
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
|
||||
--bg-darker-transparent: #0a092dce;
|
||||
--bg-ability: #010112;
|
||||
|
|
|
|||
|
|
@ -220,18 +220,20 @@ export const calendarEditPage = (eventId?: number) =>
|
|||
`/calendar/new${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
export const calendarReportWinnersPage = (eventId: number) =>
|
||||
`/calendar/${eventId}/report-winners`;
|
||||
export const tournamentPage = (eventId: number) => `/to/${eventId}`;
|
||||
export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`;
|
||||
export const tournamentTeamPage = ({
|
||||
eventId,
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: {
|
||||
eventId: number;
|
||||
tournamentId: number;
|
||||
tournamentTeamId: number;
|
||||
}) => `/to/${eventId}/teams/${tournamentTeamId}`;
|
||||
export const tournamentRegisterPage = (eventId: number) =>
|
||||
`/to/${eventId}/register`;
|
||||
export const tournamentMapsPage = (eventId: number) => `/to/${eventId}/maps`;
|
||||
export const tournamentAdminPage = (eventId: number) => `/to/${eventId}/admin`;
|
||||
}) => `/to/${tournamentId}/teams/${tournamentTeamId}`;
|
||||
export const tournamentRegisterPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/register`;
|
||||
export const tournamentMapsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/maps`;
|
||||
export const tournamentAdminPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/admin`;
|
||||
export const tournamentBracketsPage = ({
|
||||
tournamentId,
|
||||
bracketIdx,
|
||||
|
|
@ -242,32 +244,35 @@ export const tournamentBracketsPage = ({
|
|||
`/to/${tournamentId}/brackets${
|
||||
typeof bracketIdx === "number" ? `?idx=${bracketIdx}` : ""
|
||||
}`;
|
||||
export const tournamentBracketsSubscribePage = (eventId: number) =>
|
||||
`/to/${eventId}/brackets/subscribe`;
|
||||
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/brackets/subscribe`;
|
||||
export const tournamentMatchPage = ({
|
||||
eventId,
|
||||
tournamentId,
|
||||
matchId,
|
||||
}: {
|
||||
eventId: number;
|
||||
tournamentId: number;
|
||||
matchId: number;
|
||||
}) => `/to/${eventId}/matches/${matchId}`;
|
||||
}) => `/to/${tournamentId}/matches/${matchId}`;
|
||||
export const tournamentMatchSubscribePage = ({
|
||||
eventId,
|
||||
tournamentId,
|
||||
matchId,
|
||||
}: {
|
||||
eventId: number;
|
||||
tournamentId: number;
|
||||
matchId: number;
|
||||
}) => `/to/${eventId}/matches/${matchId}/subscribe`;
|
||||
}) => `/to/${tournamentId}/matches/${matchId}/subscribe`;
|
||||
export const tournamentJoinPage = ({
|
||||
eventId,
|
||||
tournamentId,
|
||||
inviteCode,
|
||||
}: {
|
||||
eventId: number;
|
||||
tournamentId: number;
|
||||
inviteCode: string;
|
||||
}) => `/to/${eventId}/join?code=${inviteCode}`;
|
||||
}) => `/to/${tournamentId}/join?code=${inviteCode}`;
|
||||
export const tournamentSubsPage = (tournamentId: number) => {
|
||||
return `/to/${tournamentId}/subs`;
|
||||
};
|
||||
export const tournamentStreamsPage = (tournamentId: number) => {
|
||||
return `/to/${tournamentId}/streams`;
|
||||
};
|
||||
|
||||
export const sendouQInviteLink = (inviteCode: string) =>
|
||||
`${SENDOUQ_PAGE}?${JOIN_CODE_SEARCH_PARAM_KEY}=${inviteCode}`;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ export function falsyToNull(value: unknown): unknown {
|
|||
return null;
|
||||
}
|
||||
|
||||
export function nullLiteraltoNull(value: unknown): unknown {
|
||||
if (value === "null") return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function jsonParseable(value: unknown) {
|
||||
try {
|
||||
JSON.parse(value as string);
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -317,7 +317,7 @@ test.describe("Tournament bracket", () => {
|
|||
url: tournamentBracketsPage({ tournamentId }),
|
||||
});
|
||||
|
||||
await page.getByText("Underground bracket").click();
|
||||
await page.getByRole("button", { name: "Underground" }).click();
|
||||
await page.getByTestId("check-in-bracket-button").click();
|
||||
|
||||
await impersonate(page);
|
||||
|
|
@ -378,4 +378,66 @@ test.describe("Tournament bracket", () => {
|
|||
page.locator('[data-testid="mates-cell-placement-0"] li'),
|
||||
).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("locks/unlocks matches & sets match as casted", async ({ page }) => {
|
||||
const tournamentId = 2;
|
||||
|
||||
await seed(page);
|
||||
await impersonate(page);
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: tournamentPage(tournamentId),
|
||||
});
|
||||
|
||||
await page.getByTestId("admin-tab").click();
|
||||
|
||||
await page.getByLabel("Action").selectOption("CHECK_OUT");
|
||||
|
||||
for (let id = 103; id < 115; id++) {
|
||||
await page.getByLabel("Team").selectOption(String(id));
|
||||
await submit(page);
|
||||
}
|
||||
|
||||
await page.getByLabel("Twitch accounts").fill("test");
|
||||
await page.getByTestId("save-cast-twitch-accounts-button").click();
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: tournamentBracketsPage({ tournamentId }),
|
||||
});
|
||||
|
||||
await page.getByTestId("finalize-bracket-button").click();
|
||||
|
||||
await page.locator('[data-match-id="1"]').click();
|
||||
await reportResult({
|
||||
page,
|
||||
amountOfMapsToReport: 2,
|
||||
sidesWithMoreThanFourPlayers: ["last"],
|
||||
});
|
||||
await backToBracket(page);
|
||||
|
||||
await page.locator('[data-match-id="3"]').click();
|
||||
await page.getByTestId("cast-info-submit-button").click();
|
||||
await backToBracket(page);
|
||||
|
||||
await page.locator('[data-match-id="2"]').click();
|
||||
await reportResult({
|
||||
page,
|
||||
amountOfMapsToReport: 2,
|
||||
sidesWithMoreThanFourPlayers: ["last"],
|
||||
});
|
||||
await backToBracket(page);
|
||||
|
||||
await expect(page.getByText("⚪ CAST")).toBeVisible();
|
||||
await page.locator('[data-match-id="3"]').click();
|
||||
await expect(page.getByText("Match locked to be casted")).toBeVisible();
|
||||
await page.getByTestId("cast-info-submit-button").click();
|
||||
await expect(page.getByTestId("stage-banner")).toBeVisible();
|
||||
|
||||
await page.getByTestId("cast-info-select").selectOption("test");
|
||||
await page.getByTestId("cast-info-submit-button").click();
|
||||
await backToBracket(page);
|
||||
await expect(page.getByText("🔴 LIVE")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ test.describe("Tournament staff", () => {
|
|||
|
||||
await navigate({
|
||||
page,
|
||||
url: tournamentMatchPage({ eventId: TOURNAMENT_ID, matchId: 2 }),
|
||||
url: tournamentMatchPage({ tournamentId: TOURNAMENT_ID, matchId: 2 }),
|
||||
});
|
||||
|
||||
const roomPassSelector = page.getByTestId("room-pass");
|
||||
|
|
@ -131,7 +131,7 @@ test.describe("Tournament staff", () => {
|
|||
await impersonate(page, NZAP_TEST_ID);
|
||||
await navigate({
|
||||
page,
|
||||
url: tournamentMatchPage({ eventId: TOURNAMENT_ID, matchId: 2 }),
|
||||
url: tournamentMatchPage({ tournamentId: TOURNAMENT_ID, matchId: 2 }),
|
||||
});
|
||||
|
||||
await expect(roomPassSelector).toBeVisible();
|
||||
|
|
|
|||
15
migrations/047-bracket-stuff.js
Normal file
15
migrations/047-bracket-stuff.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "TournamentStage" add "createdAt" integer`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "Tournament" add "castedMatchesInfo" text`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "TournamentMatch" drop "childCount"`,
|
||||
).run();
|
||||
})();
|
||||
};
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"remove-vodder": "node --experimental-specifier-resolution=node -r @swc-node/register -r tsconfig-paths/register scripts/remove-vodder.ts",
|
||||
"transfer-weapon-pools": "node --experimental-specifier-resolution=node -r @swc-node/register -r tsconfig-paths/register scripts/transfer-weapon-pools.ts",
|
||||
"reopen-tournament": "node --experimental-specifier-resolution=node -r @swc-node/register -r tsconfig-paths/register scripts/reopen-tournament.ts",
|
||||
"refresh-prod-db": "node --experimental-specifier-resolution=node -r @swc-node/register -r tsconfig-paths/register scripts/refresh-prod-db.ts && npm run migrate up",
|
||||
"refresh-prod-db": "node --experimental-specifier-resolution=node -r @swc-node/register -r tsconfig-paths/register scripts/refresh-prod-db.ts && cross-env DB_PATH=db-prod.sqlite3 npm run migrate up",
|
||||
"lint:ts": "eslint . --ext .ts,.tsx",
|
||||
"lint:css": "stylelint \"app/styles/**/*.css\"",
|
||||
"prettier:check": "prettier --check . --log-level warn",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user