mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 17:27:09 -05:00
618 lines
18 KiB
TypeScript
618 lines
18 KiB
TypeScript
import type {
|
|
ActionFunction,
|
|
LinksFunction,
|
|
LoaderFunctionArgs,
|
|
} from "@remix-run/node";
|
|
import {
|
|
Link,
|
|
useLoaderData,
|
|
useOutletContext,
|
|
useRevalidator,
|
|
} from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import { nanoid } from "nanoid";
|
|
import * as React from "react";
|
|
import { useEventSource } from "remix-utils/sse/react";
|
|
import invariant from "tiny-invariant";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { LinkButton } from "~/components/Button";
|
|
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
|
import { sql } from "~/db/sql";
|
|
import {
|
|
tournamentIdFromParams,
|
|
type TournamentLoaderData,
|
|
} from "~/features/tournament";
|
|
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
|
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
|
import { requireUser, useUser } from "~/features/auth/core";
|
|
import { getUserId } from "~/features/auth/core/user.server";
|
|
import { canAdminTournament, canReportTournamentScore } from "~/permissions";
|
|
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import {
|
|
tournamentBracketsPage,
|
|
tournamentMatchSubscribePage,
|
|
tournamentTeamPage,
|
|
userPage,
|
|
} from "~/utils/urls";
|
|
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
|
|
import { ScoreReporter } from "../components/ScoreReporter";
|
|
import { getTournamentManager } from "../core/brackets-manager";
|
|
import { emitter } from "../core/emitters.server";
|
|
import { resolveMapList } from "../core/mapList.server";
|
|
import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server";
|
|
import { findMatchById } from "../queries/findMatchById.server";
|
|
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
|
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
|
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
|
import { matchSchema } from "../tournament-bracket-schemas.server";
|
|
import {
|
|
bracketSubscriptionKey,
|
|
matchIdFromParams,
|
|
matchSubscriptionKey,
|
|
} from "../tournament-bracket-utils";
|
|
import bracketStyles from "../tournament-bracket.css";
|
|
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
|
import { logger } from "~/utils/logger";
|
|
|
|
export const links: LinksFunction = () => [
|
|
{
|
|
rel: "stylesheet",
|
|
href: bracketStyles,
|
|
},
|
|
];
|
|
|
|
export const action: ActionFunction = async ({ params, request }) => {
|
|
const user = await requireUser(request);
|
|
const matchId = matchIdFromParams(params);
|
|
const match = notFoundIfFalsy(findMatchById(matchId));
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: matchSchema,
|
|
});
|
|
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
const tournament = notFoundIfFalsy(
|
|
await TournamentRepository.findById(tournamentId),
|
|
);
|
|
|
|
const validateCanReportScore = () => {
|
|
const isMemberOfATeamInTheMatch = match.players.some(
|
|
(p) => p.id === user?.id,
|
|
);
|
|
|
|
validate(
|
|
canReportTournamentScore({
|
|
tournament,
|
|
match,
|
|
isMemberOfATeamInTheMatch,
|
|
user,
|
|
}),
|
|
"Unauthorized",
|
|
401,
|
|
);
|
|
};
|
|
|
|
const manager = getTournamentManager("SQL");
|
|
|
|
const scores: [number, number] = [
|
|
match.opponentOne?.score ?? 0,
|
|
match.opponentTwo?.score ?? 0,
|
|
];
|
|
|
|
switch (data._action) {
|
|
case "REPORT_SCORE": {
|
|
// they are trying to report score that was already reported
|
|
// assume that it was already reported and make their page refresh
|
|
if (data.position !== scores[0] + scores[1]) {
|
|
return null;
|
|
}
|
|
|
|
validateCanReportScore();
|
|
validate(
|
|
match.opponentOne?.id === data.winnerTeamId ||
|
|
match.opponentTwo?.id === data.winnerTeamId,
|
|
"Winner team id is invalid",
|
|
);
|
|
|
|
const mapList =
|
|
match.opponentOne?.id && match.opponentTwo?.id
|
|
? resolveMapList({
|
|
bestOf: match.bestOf,
|
|
tournamentId,
|
|
matchId,
|
|
teams: [match.opponentOne.id, match.opponentTwo.id],
|
|
mapPickingStyle: match.mapPickingStyle,
|
|
})
|
|
: null;
|
|
const currentMap = mapList?.[data.position];
|
|
invariant(currentMap, "Can't resolve current map");
|
|
|
|
const scoreToIncrement = () => {
|
|
if (data.winnerTeamId === match.opponentOne?.id) return 0;
|
|
if (data.winnerTeamId === match.opponentTwo?.id) return 1;
|
|
|
|
validate(false, "Winner team id is invalid");
|
|
};
|
|
|
|
scores[scoreToIncrement()]++;
|
|
|
|
sql.transaction(() => {
|
|
manager.update.match({
|
|
id: match.id,
|
|
opponent1: {
|
|
score: scores[0],
|
|
result:
|
|
scores[0] === Math.ceil(match.bestOf / 2) ? "win" : undefined,
|
|
},
|
|
opponent2: {
|
|
score: scores[1],
|
|
result:
|
|
scores[1] === Math.ceil(match.bestOf / 2) ? "win" : undefined,
|
|
},
|
|
});
|
|
|
|
const result = insertTournamentMatchGameResult({
|
|
matchId: match.id,
|
|
mode: currentMap.mode,
|
|
stageId: currentMap.stageId,
|
|
reporterId: user.id,
|
|
winnerTeamId: data.winnerTeamId,
|
|
number: data.position + 1,
|
|
source: String(currentMap.source),
|
|
});
|
|
|
|
for (const userId of data.playerIds) {
|
|
insertTournamentMatchGameResultParticipant({
|
|
matchGameResultId: result.id,
|
|
userId,
|
|
});
|
|
}
|
|
})();
|
|
|
|
break;
|
|
}
|
|
case "UNDO_REPORT_SCORE": {
|
|
validateCanReportScore();
|
|
// they are trying to remove score from the past
|
|
if (data.position !== scores[0] + scores[1] - 1) {
|
|
return null;
|
|
}
|
|
|
|
const results = findResultsByMatchId(matchId);
|
|
const lastResult = results[results.length - 1];
|
|
invariant(lastResult, "Last result is missing");
|
|
|
|
const shouldReset = results.length === 1;
|
|
|
|
if (lastResult.winnerTeamId === match.opponentOne?.id) {
|
|
scores[0]--;
|
|
} else {
|
|
scores[1]--;
|
|
}
|
|
|
|
logger.info(
|
|
`Undoing score: Position: ${data.position}; User ID: ${user.id}; Match ID: ${match.id}`,
|
|
);
|
|
|
|
sql.transaction(() => {
|
|
deleteTournamentMatchGameResultById(lastResult.id);
|
|
|
|
manager.update.match({
|
|
id: match.id,
|
|
opponent1: {
|
|
score: shouldReset ? undefined : scores[0],
|
|
},
|
|
opponent2: {
|
|
score: shouldReset ? undefined : scores[1],
|
|
},
|
|
});
|
|
|
|
if (shouldReset) {
|
|
manager.reset.matchResults(match.id);
|
|
}
|
|
})();
|
|
|
|
break;
|
|
}
|
|
// TODO: bug where you can reopen losers finals after winners finals
|
|
case "REOPEN_MATCH": {
|
|
const scoreOne = match.opponentOne?.score ?? 0;
|
|
const scoreTwo = match.opponentTwo?.score ?? 0;
|
|
invariant(typeof scoreOne === "number", "Score one is missing");
|
|
invariant(typeof scoreTwo === "number", "Score two is missing");
|
|
invariant(scoreOne !== scoreTwo, "Scores are equal");
|
|
|
|
validate(canAdminTournament({ tournament, user }));
|
|
|
|
const results = findResultsByMatchId(matchId);
|
|
const lastResult = results[results.length - 1];
|
|
invariant(lastResult, "Last result is missing");
|
|
|
|
if (scoreOne > scoreTwo) {
|
|
scores[0]--;
|
|
} else {
|
|
scores[1]--;
|
|
}
|
|
|
|
logger.info(
|
|
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}`,
|
|
);
|
|
|
|
try {
|
|
sql.transaction(() => {
|
|
deleteTournamentMatchGameResultById(lastResult.id);
|
|
manager.update.match({
|
|
id: match.id,
|
|
opponent1: {
|
|
score: scores[0],
|
|
result: undefined,
|
|
},
|
|
opponent2: {
|
|
score: scores[1],
|
|
result: undefined,
|
|
},
|
|
});
|
|
})();
|
|
} catch (err) {
|
|
if (!(err instanceof Error)) throw err;
|
|
|
|
if (err.message.includes("locked")) {
|
|
return { error: "locked" };
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
|
|
break;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
emitter.emit(matchSubscriptionKey(match.id), {
|
|
eventId: nanoid(),
|
|
userId: user.id,
|
|
});
|
|
emitter.emit(bracketSubscriptionKey(tournament.id), {
|
|
matchId: match.id,
|
|
scores,
|
|
isOver:
|
|
scores[0] === Math.ceil(match.bestOf / 2) ||
|
|
scores[1] === Math.ceil(match.bestOf / 2),
|
|
});
|
|
|
|
return null;
|
|
};
|
|
|
|
export type TournamentMatchLoaderData = typeof loader;
|
|
|
|
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|
const user = await getUserId(request);
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
const matchId = matchIdFromParams(params);
|
|
|
|
const match = notFoundIfFalsy(findMatchById(matchId));
|
|
|
|
const mapList =
|
|
match.opponentOne?.id && match.opponentTwo?.id
|
|
? resolveMapList({
|
|
bestOf: match.bestOf,
|
|
tournamentId,
|
|
matchId,
|
|
teams: [match.opponentOne.id, match.opponentTwo.id],
|
|
mapPickingStyle: match.mapPickingStyle,
|
|
})
|
|
: null;
|
|
|
|
const scoreSum =
|
|
(match.opponentOne?.score ?? 0) + (match.opponentTwo?.score ?? 0);
|
|
|
|
const currentMap = mapList?.[scoreSum];
|
|
|
|
const showChat =
|
|
match.players.some((p) => p.id === user?.id) ||
|
|
canAdminTournament({
|
|
user,
|
|
tournament: notFoundIfFalsy(
|
|
await TournamentRepository.findById(tournamentId),
|
|
),
|
|
});
|
|
|
|
const matchIsOver =
|
|
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
|
|
|
|
return {
|
|
match: {
|
|
...match,
|
|
chatCode: showChat ? match.chatCode : null,
|
|
},
|
|
results: findResultsByMatchId(matchId),
|
|
seeds: resolveSeeds(),
|
|
currentMap,
|
|
modes: mapList?.map((map) => map.mode),
|
|
matchIsOver,
|
|
};
|
|
|
|
function resolveSeeds() {
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
const teams = findTeamsByTournamentId(tournamentId);
|
|
|
|
const teamOneIndex = teams.findIndex(
|
|
(team) => team.id === match.opponentOne?.id,
|
|
);
|
|
const teamTwoIndex = teams.findIndex(
|
|
(team) => team.id === match.opponentTwo?.id,
|
|
);
|
|
|
|
return [
|
|
teamOneIndex !== -1 ? teamOneIndex + 1 : null,
|
|
teamTwoIndex !== -1 ? teamTwoIndex + 1 : null,
|
|
];
|
|
}
|
|
};
|
|
|
|
export default function TournamentMatchPage() {
|
|
const user = useUser();
|
|
const visibility = useVisibilityChange();
|
|
const { revalidate } = useRevalidator();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
React.useEffect(() => {
|
|
if (visibility !== "visible" || data.matchIsOver) return;
|
|
|
|
revalidate();
|
|
}, [visibility, revalidate, data.matchIsOver]);
|
|
|
|
const isMemberOfATeamInTheMatch = data.match.players.some(
|
|
(p) => p.id === user?.id,
|
|
);
|
|
|
|
const type = canReportTournamentScore({
|
|
tournament: parentRouteData.tournament,
|
|
match: data.match,
|
|
isMemberOfATeamInTheMatch,
|
|
user,
|
|
})
|
|
? "EDIT"
|
|
: "OTHER";
|
|
|
|
const showRosterPeek = () => {
|
|
if (data.matchIsOver) return false;
|
|
|
|
if (!data.match.opponentOne?.id || !data.match.opponentTwo?.id) return true;
|
|
|
|
return type !== "EDIT";
|
|
};
|
|
|
|
return (
|
|
<div className="stack lg">
|
|
{!data.matchIsOver && visibility !== "hidden" ? <AutoRefresher /> : null}
|
|
<div className="flex horizontal justify-between items-center">
|
|
{/* TODO: better title */}
|
|
<h2 className="text-lighter text-lg">Match #{data.match.id}</h2>
|
|
<LinkButton
|
|
to={tournamentBracketsPage(parentRouteData.tournament.id)}
|
|
variant="outlined"
|
|
size="tiny"
|
|
className="w-max"
|
|
icon={<ArrowLongLeftIcon />}
|
|
testId="back-to-bracket-button"
|
|
>
|
|
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}
|
|
/>
|
|
) : null}
|
|
{showRosterPeek() ? (
|
|
<Rosters
|
|
teams={[data.match.opponentOne?.id, data.match.opponentTwo?.id]}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutoRefresher() {
|
|
useAutoRefresh();
|
|
|
|
return null;
|
|
}
|
|
|
|
function useAutoRefresh() {
|
|
const { revalidate } = useRevalidator();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const data = useLoaderData<typeof loader>();
|
|
const lastEventId = useEventSource(
|
|
tournamentMatchSubscribePage({
|
|
eventId: parentRouteData.tournament.id,
|
|
matchId: data.match.id,
|
|
}),
|
|
{
|
|
event: matchSubscriptionKey(data.match.id),
|
|
},
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (lastEventId) {
|
|
revalidate();
|
|
}
|
|
}, [lastEventId, revalidate]);
|
|
}
|
|
|
|
function MapListSection({
|
|
teams,
|
|
type,
|
|
}: {
|
|
teams: [id: number, id: number];
|
|
type: "EDIT" | "OTHER";
|
|
}) {
|
|
const data = useLoaderData<typeof loader>();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
|
|
const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]);
|
|
const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]);
|
|
|
|
if (!teamOne || !teamTwo) return null;
|
|
|
|
invariant(data.currentMap, "No map found for this score");
|
|
invariant(data.modes, "No modes found for this map list");
|
|
|
|
return (
|
|
<ScoreReporter
|
|
currentStageWithMode={data.currentMap}
|
|
teams={[teamOne, teamTwo]}
|
|
modes={data.modes}
|
|
type={type}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ResultsSection() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const [selectedResultIndex, setSelectedResultIndex] = useSearchParamState({
|
|
defaultValue: data.results.length - 1,
|
|
name: "result",
|
|
revive: (value) => {
|
|
const maybeIndex = Number(value);
|
|
if (!Number.isInteger(maybeIndex)) return;
|
|
if (maybeIndex < 0 || maybeIndex >= data.results.length) return;
|
|
|
|
return maybeIndex;
|
|
},
|
|
});
|
|
|
|
const result = data.results[selectedResultIndex];
|
|
invariant(result, "Result is missing");
|
|
|
|
const teamOne = parentRouteData.teams.find(
|
|
(team) => team.id === data.match.opponentOne?.id,
|
|
);
|
|
const teamTwo = parentRouteData.teams.find(
|
|
(team) => team.id === data.match.opponentTwo?.id,
|
|
);
|
|
|
|
if (!teamOne || !teamTwo) {
|
|
throw new Error("Team is missing");
|
|
}
|
|
|
|
return (
|
|
<ScoreReporter
|
|
currentStageWithMode={result}
|
|
teams={[teamOne, teamTwo]}
|
|
modes={data.results.map((result) => result.mode)}
|
|
selectedResultIndex={selectedResultIndex}
|
|
setSelectedResultIndex={setSelectedResultIndex}
|
|
result={result}
|
|
type="OTHER"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Rosters({
|
|
teams,
|
|
}: {
|
|
teams: [id: number | null | undefined, id: number | null | undefined];
|
|
}) {
|
|
const data = useLoaderData<typeof loader>();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
|
|
const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]);
|
|
const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]);
|
|
const teamOnePlayers = data.match.players.filter(
|
|
(p) => p.tournamentTeamId === teamOne?.id,
|
|
);
|
|
const teamTwoPlayers = data.match.players.filter(
|
|
(p) => p.tournamentTeamId === teamTwo?.id,
|
|
);
|
|
|
|
return (
|
|
<div className="tournament-bracket__rosters">
|
|
<div>
|
|
<div className="stack xs horizontal items-center text-lighter">
|
|
<div className="tournament-bracket__team-one-dot" />
|
|
Team 1
|
|
</div>
|
|
<h2
|
|
className={clsx("text-sm", {
|
|
"text-lighter": !teamOne,
|
|
})}
|
|
>
|
|
{teamOne ? (
|
|
<Link
|
|
to={tournamentTeamPage({
|
|
eventId: parentRouteData.tournament.id,
|
|
tournamentTeamId: teamOne.id,
|
|
})}
|
|
className="text-main-forced font-bold"
|
|
>
|
|
{teamOne.name}
|
|
</Link>
|
|
) : (
|
|
"Waiting on team"
|
|
)}
|
|
</h2>
|
|
{teamOnePlayers.length > 0 ? (
|
|
<ul className="stack xs mt-2">
|
|
{teamOnePlayers.map((p) => {
|
|
return (
|
|
<li key={p.id}>
|
|
<Link to={userPage(p)} className="stack horizontal sm">
|
|
<Avatar user={p} size="xxs" />
|
|
{p.discordName}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
<div>
|
|
<div className="stack xs horizontal items-center text-lighter">
|
|
<div className="tournament-bracket__team-two-dot" />
|
|
Team 2
|
|
</div>
|
|
<h2 className={clsx("text-sm", { "text-lighter": !teamTwo })}>
|
|
{teamTwo ? (
|
|
<Link
|
|
to={tournamentTeamPage({
|
|
eventId: parentRouteData.tournament.id,
|
|
tournamentTeamId: teamTwo.id,
|
|
})}
|
|
className="text-main-forced font-bold"
|
|
>
|
|
{teamTwo.name}
|
|
</Link>
|
|
) : (
|
|
"Waiting on team"
|
|
)}
|
|
</h2>
|
|
{teamTwoPlayers.length > 0 ? (
|
|
<ul className="stack xs mt-2">
|
|
{teamTwoPlayers.map((p) => {
|
|
return (
|
|
<li key={p.id}>
|
|
<Link to={userPage(p)} className="stack horizontal sm">
|
|
<Avatar user={p} size="xxs" />
|
|
{p.discordName}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|