import type { SerializeFrom } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import type { TFunction } from "i18next"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { SendouTab, SendouTabList, SendouTabPanel, SendouTabs, } from "~/components/elements/Tabs"; import { Image } from "~/components/Image"; import { CheckmarkIcon } from "~/components/icons/Checkmark"; import { CrossIcon } from "~/components/icons/Cross"; import { PickIcon } from "~/components/icons/Pick"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useChat } from "~/features/chat/chat-hooks"; import { Chat } from "~/features/chat/components/Chat"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import type { StageId } from "~/modules/in-game-lists/types"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; import { nullFilledArray } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import type { Unpacked } from "~/utils/types"; import { modeImageUrl, specialWeaponImageUrl, stageImageUrl, } from "~/utils/urls"; import type { Bracket } from "../core/Bracket"; import * as PickBan from "../core/PickBan"; import type { TournamentDataTeam } from "../core/Tournament.server"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { groupNumberToLetters, mapCountPlayedInSetWithCertainty, matchIsLocked, pickInfoText, resolveHostingTeam, resolveRoomPass, tournamentTeamToActiveRosterUserIds, } from "../tournament-bracket-utils"; import { MatchActions } from "./MatchActions"; import { MatchRosters } from "./MatchRosters"; export type Result = Unpacked< SerializeFrom["results"] >; export function StartedMatch({ teams, currentStageWithMode, selectedResultIndex, setSelectedResultIndex, result, type, }: { teams: [TournamentDataTeam, TournamentDataTeam]; result?: Result; currentStageWithMode?: TournamentMapListMap; selectedResultIndex?: number; // if this is set it means the component is being used in presentation manner setSelectedResultIndex?: (index: number) => void; type: "EDIT" | "OTHER"; }) { const { t } = useTranslation(["tournament"]); const isMounted = useIsMounted(); const user = useUser(); const tournament = useTournament(); const data = useLoaderData(); const scoreOne = data.match.opponentOne?.score ?? 0; const scoreTwo = data.match.opponentTwo?.score ?? 0; const currentPosition = scoreOne + scoreTwo; const presentational = Boolean(setSelectedResultIndex); const showFullInfos = !presentational && type === "EDIT"; const isMemberOfTeamParticipating = data.match.players.some( (p) => p.id === user?.id, ); const hostingTeamId = resolveHostingTeam(teams).id; const poolCode = React.useMemo(() => { const match = tournament.brackets .flatMap((b) => b.data.match) .find((m) => m.id === data.match.id); const hasRoundRobin = tournament.brackets.some( (b) => b.type === "round_robin", ); const bracketIdx = tournament.brackets.findIndex((b) => b.data.match.some((m) => m.id === data.match.id), ); const bracket = tournament.brackets[bracketIdx] as Bracket | undefined; const group = tournament.brackets .flatMap((b) => b.data.group) .find((group) => group.id === match?.group_id); return tournament.resolvePoolCode({ hostingTeamId, groupLetters: group && bracket?.type === "round_robin" ? groupNumberToLetters(group.number) : undefined, bracketNumber: hasRoundRobin && bracket?.type !== "round_robin" ? bracketIdx + 1 : undefined, }); }, [tournament, hostingTeamId, data.match.id]); // using team ids as the seed to ensure grand finals and bracket reset have the same room pass const roomPassSeed = [data.match.opponentOne?.id, data.match.opponentOne?.id] .filter((value) => typeof value === "number") .sort((a, b) => a - b) .join("-"); const roundInfos = [ showFullInfos ? ( {t("tournament:match.hosts", { teamName: resolveHostingTeam(teams).name, })} ) : null, showFullInfos ? ( {t("tournament:match.pass")}{" "} {resolveRoomPass(roomPassSeed)} ) : null, showFullInfos ? ( {t("tournament:match.pool")} {poolCode.prefix} {poolCode.suffix} ) : null, {data.match.roundMaps?.type === "PLAY_ALL" ? t("tournament:match.score.playAll", { scoreOne, scoreTwo, bestOf: data.match.bestOf, }) : t("tournament:match.score", { scoreOne, scoreTwo, bestOf: data.match.bestOf, })} , tournament.ctx.settings.enableNoScreenToggle ? ( team.noScreen)} /> ) : null, ]; return (
{currentPosition > 0 && !presentational && type === "EDIT" && (tournament.isOrganizer(user) || isMemberOfTeamParticipating) && (
{t("tournament:match.action.undoLastScore")}
)} {tournament.isOrganizer(user) && tournament.matchCanBeReopened(data.match.id) && presentational && (
{t("tournament:match.action.reopenMatch")}
)}
{type === "EDIT" || presentational ? ( ) : null} {result ? (
{isMounted ? databaseTimestampToDate(result.createdAt).toLocaleString() : "t"}
) : null}
); } function FancyStageBanner({ stage, infos, children, teams, matchIsLocked, }: { stage?: TournamentMapListMap; infos?: (JSX.Element | null)[]; children?: React.ReactNode; teams: [TournamentDataTeam, TournamentDataTeam]; matchIsLocked: boolean; }) { const data = useLoaderData(); const { t } = useTranslation(["game-misc", "tournament"]); const tournament = useTournament(); const stageNameToBannerImageUrl = (stageId: StageId) => { return `${stageImageUrl(stageId)}.png`; }; const banPickingTeam = () => { if ( !data.match.roundMaps || !data.match.opponentOne?.id || !data.match.opponentTwo?.id ) { return null; } const pickingTeamId = PickBan.turnOf({ results: data.results, maps: data.match.roundMaps, teams: [data.match.opponentOne.id, data.match.opponentTwo.id], mapList: data.mapList, }); return pickingTeamId ? teams.find((t) => t.id === pickingTeamId) : null; }; const style = { "--_tournament-bg-url": stage ? `url("${stageNameToBannerImageUrl(stage.stageId)}")` : undefined, }; const inBanPhase = data.match.roundMaps?.pickBan === "BAN_2" && data.mapList && data.mapList.filter((m) => m.bannedByTournamentTeamId).length < 2; const waitingForActiveRosterSelectionFor = (() => { if (data.results.length > 0) return null; const teamOneMissing = !tournamentTeamToActiveRosterUserIds( teams[0], tournament.minMembersPerTeam, ); const teamTwoMissing = !tournamentTeamToActiveRosterUserIds( teams[1], tournament.minMembersPerTeam, ); if (teamOneMissing && teamTwoMissing) { return "BOTH"; } if (teamOneMissing) { return teams[0].name; } if (teamTwoMissing) { return teams[1].name; } return null; })(); const waitingForLeagueRoundToStart = (() => { const date = resolveLeagueRoundStartDate(tournament, data.match.roundId); if (!date) return false; return date > new Date(); })(); return ( <> {inBanPhase ? (
Banning phase
Waiting for {banPickingTeam()?.name}
) : !stage ? (
Counterpick
Waiting for {banPickingTeam()?.name}
{children}
) : matchIsLocked ? (
Match locked to be casted
Please wait for staff to unlock
) : waitingForLeagueRoundToStart ? (
Waiting for league round to start
Round playable from{" "} {resolveLeagueRoundStartDate( tournament, data.match.roundId, )!.toLocaleDateString()}{" "} onwards
) : waitingForActiveRosterSelectionFor ? (
Active rosters need to be selected
Waiting on{" "} {waitingForActiveRosterSelectionFor === "BOTH" ? "both teams" : waitingForActiveRosterSelectionFor}
) : (

{t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "} {t(`game-misc:STAGE_${stage.stageId}`)} {t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "} {t(`game-misc:STAGE_${stage.stageId}`)}

{pickInfoText({ t: t as unknown as TFunction<["tournament"]>, teams, map: stage, })}

{children}
)} {infos && (
{infos.filter(Boolean).map((info, i) => (
{info}
))}
)} ); } function ModeProgressIndicator({ scores, bestOf, selectedResultIndex, setSelectedResultIndex, }: { scores: [number, number]; bestOf: number; selectedResultIndex?: number; setSelectedResultIndex?: (index: number) => void; }) { const tournament = useTournament(); const data = useLoaderData(); const { t } = useTranslation(["game-misc"]); const maxIndexThatWillBePlayedForSure = data.match.roundMaps?.type === "PLAY_ALL" ? bestOf - 1 : mapCountPlayedInSetWithCertainty({ bestOf, scores }) - 1; const indexWithBansConsider = (realIdx: number) => { let result = 0; for (const [idx, map] of (data.mapList ?? []).entries()) { if (idx === realIdx) { break; } if (map.bannedByTournamentTeamId) { continue; } result++; } return result; }; // TODO: this should be button when we click on it return (
{nullFilledArray( Math.max(data.mapList?.length ?? 0, data.match.roundMaps?.count ?? 0), ).map((_, i) => { const map = data.mapList?.[i]; const adjustedI = indexWithBansConsider(i); if ( data.matchIsOver && !data.results[adjustedI] && !map?.bannedByTournamentTeamId ) { return null; } if (!map?.mode) { return (
); } if (map.bannedByTournamentTeamId) { const bannerTeamName = tournament.ctx.teams.find( (t) => t.id === map.bannedByTournamentTeamId, )?.name; return ( {t(`game-misc:MODE_LONG_${map.mode}`)} } >
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "} {t(`game-misc:STAGE_${map.stageId}`)}
Banned by {bannerTeamName}
); } return ( {t(`game-misc:MODE_LONG_${map.mode}`)} setSelectedResultIndex?.(adjustedI)} testId={`mode-progress-${map.mode}`} /> ); })}
); } function StartedMatchTabs({ presentational, scores, teams, result, }: { presentational?: boolean; scores: [number, number]; teams: [TournamentDataTeam, TournamentDataTeam]; result?: Result; }) { const user = useUser(); const tournament = useTournament(); const data = useLoaderData(); const [_unseenMessages, setUnseenMessages] = React.useState(0); const [chatVisible, setChatVisible] = React.useState(false); const [selectedTabKey, setSelectedTabKey] = useSearchParamState({ defaultValue: "rosters", name: "tab", revive: (value) => ["chat", "rosters", "actions"].includes(value) ? value : null, }); // TODO: resolve this on server (notice it is copy-pasted now) const chatUsers = React.useMemo(() => { return Object.fromEntries( [ ...data.match.players.map((p) => ({ ...p, title: undefined })), ...(tournament.ctx.organization?.members ?? []).map((m) => ({ ...m, title: m.role === "STREAMER" ? "Stream" : "TO", })), ...tournament.ctx.staff.map((s) => ({ ...s, title: s.role === "STREAMER" ? "Stream" : "TO", })), { ...tournament.ctx.author, title: "TO", }, ].map((p) => [p.id, p]), ); }, [data, tournament]); const showChat = (() => { if (!data.match.chatCode) return false; if (tournament.ctx.isFinalized && !tournament.isOrganizer(user)) { return false; } const oneMonthAgo = new Date(); oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); if ( tournament.ctx.startTime < oneMonthAgo && !tournament.isLeagueDivision ) { return false; } return ( data.match.players.some((p) => p.id === user?.id) || tournament.isOrganizerOrStreamer(user) ); })(); const rooms = React.useMemo(() => { return showChat && data.match.chatCode ? [ { code: data.match.chatCode, label: "Match", }, ] : []; }, [showChat, data.match.chatCode]); const onNewMessage = React.useCallback(() => { setUnseenMessages((msg) => msg + 1); }, []); const chat = useChat({ rooms, onNewMessage }); const onChatMount = React.useCallback(() => { setChatVisible(true); }, []); const onChatUnmount = React.useCallback(() => { setChatVisible(false); setUnseenMessages(0); }, []); const unseenMessages = chatVisible ? 0 : _unseenMessages; const currentPosition = scores[0] + scores[1]; const matchActionsKey = () => [ data.match.id, tournamentTeamToActiveRosterUserIds( teams[0], tournament.minMembersPerTeam, ), tournamentTeamToActiveRosterUserIds( teams[1], tournament.minMembersPerTeam, ), result?.participants .map((p) => `${p.userId}-${p.tournamentTeamId}`) .join(","), result?.opponentOnePoints, result?.opponentTwoPoints, data.results.length, ].join("-"); return ( setSelectedTabKey(String(key))} > {showChat && ( Chat )} Rosters {presentational ? "Score" : "Actions"} ); } function ActionSectionWrapper({ children, icon, ...rest }: { children: React.ReactNode; icon?: "warning" | "info" | "success" | "error"; "justify-center"?: boolean; }) { // todo: flex-dir: column on mobile const style = icon ? { "--action-section-icon-color": `var(--theme-${icon})`, } : undefined; return (
{children}
); } function ScreenBanIcons({ banned }: { banned: boolean }) { const { t } = useTranslation(["weapons"]); return (
{banned ? : } {t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)}
); }