mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
* Initial * Handle owner leaving * Remove old team queries * Progress * Retire old toggle * e2e tests * Divide loaders/actions of team pages
1909 lines
52 KiB
TypeScript
1909 lines
52 KiB
TypeScript
import cachified from "@epic-web/cachified";
|
|
import type {
|
|
ActionFunctionArgs,
|
|
LoaderFunctionArgs,
|
|
MetaFunction,
|
|
SerializeFrom,
|
|
} from "@remix-run/node";
|
|
import { redirect } from "@remix-run/node";
|
|
import type { FetcherWithComponents } from "@remix-run/react";
|
|
import {
|
|
Link,
|
|
useFetcher,
|
|
useLoaderData,
|
|
useNavigate,
|
|
useSearchParams,
|
|
} from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { Flipped, Flipper } from "react-flip-toolkit";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Alert } from "~/components/Alert";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Button, LinkButton } from "~/components/Button";
|
|
import { WeaponCombobox } from "~/components/Combobox";
|
|
import { Divider } from "~/components/Divider";
|
|
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|
import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
|
|
import { Main } from "~/components/Main";
|
|
import { NewTabs } from "~/components/NewTabs";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import { SendouPopover } from "~/components/elements/Popover";
|
|
import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
|
|
import { CrossIcon } from "~/components/icons/Cross";
|
|
import { DiscordIcon } from "~/components/icons/Discord";
|
|
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
|
import { ScaleIcon } from "~/components/icons/Scale";
|
|
import { sql } from "~/db/sql";
|
|
import type { GroupMember, ReportedWeapon } from "~/db/types";
|
|
import { useUser } from "~/features/auth/core/user";
|
|
import { getUserId, requireUser } from "~/features/auth/core/user.server";
|
|
import * as NotificationService from "~/features/chat/NotificationService.server";
|
|
import type { ChatMessage } from "~/features/chat/chat-types";
|
|
import { Chat, type ChatProps, useChat } from "~/features/chat/components/Chat";
|
|
import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season";
|
|
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
|
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
|
import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog";
|
|
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
|
import * as QRepository from "~/features/sendouq/QRepository.server";
|
|
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import { useWindowSize } from "~/hooks/useWindowSize";
|
|
import type { MainWeaponId } from "~/modules/in-game-lists";
|
|
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
|
import { isMod } from "~/permissions";
|
|
import { joinListToNaturalString } from "~/utils/arrays";
|
|
import { cache } from "~/utils/cache.server";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { animate } from "~/utils/flip";
|
|
import invariant from "~/utils/invariant";
|
|
import { logger } from "~/utils/logger";
|
|
import { safeNumberParse } from "~/utils/number";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import {
|
|
notFoundIfFalsy,
|
|
parseParams,
|
|
parseRequestPayload,
|
|
validate,
|
|
} from "~/utils/remix.server";
|
|
import { inGameNameWithoutDiscriminator, makeTitle } from "~/utils/strings";
|
|
import type { Unpacked } from "~/utils/types";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import {
|
|
SENDOUQ_PAGE,
|
|
SENDOUQ_PREPARING_PAGE,
|
|
SENDOUQ_RULES_PAGE,
|
|
SENDOU_INK_DISCORD_URL,
|
|
navIconUrl,
|
|
preferenceEmojiUrl,
|
|
sendouQMatchPage,
|
|
specialWeaponImageUrl,
|
|
teamPage,
|
|
userSubmittedImage,
|
|
} from "~/utils/urls";
|
|
import { GroupCard } from "../components/GroupCard";
|
|
import { matchEndedAtIndex } from "../core/match";
|
|
import { compareMatchToReportedScores } from "../core/match.server";
|
|
import type { ReportedWeaponForMerging } from "../core/reported-weapons.server";
|
|
import {
|
|
mergeReportedWeapons,
|
|
reportedWeaponsToArrayOfArrays,
|
|
} from "../core/reported-weapons.server";
|
|
import { calculateMatchSkills } from "../core/skills.server";
|
|
import {
|
|
summarizeMaps,
|
|
summarizePlayerResults,
|
|
} from "../core/summarizer.server";
|
|
import { FULL_GROUP_SIZE } from "../q-constants";
|
|
import { useRecentlyReportedWeapons } from "../q-hooks";
|
|
import { matchSchema, qMatchPageParamsSchema } from "../q-schemas.server";
|
|
import { winnersArrayToWinner } from "../q-utils";
|
|
import { addDummySkill } from "../queries/addDummySkill.server";
|
|
import { addMapResults } from "../queries/addMapResults.server";
|
|
import { addPlayerResults } from "../queries/addPlayerResults.server";
|
|
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
|
|
import { addSkills } from "../queries/addSkills.server";
|
|
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
|
|
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
|
import { findMatchById } from "../queries/findMatchById.server";
|
|
import { reportScore } from "../queries/reportScore.server";
|
|
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
|
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
|
|
import "../q.css";
|
|
import { SendouSwitch } from "~/components/elements/Switch";
|
|
|
|
export const meta: MetaFunction = (args) => {
|
|
const data = args.data as SerializeFrom<typeof loader> | null;
|
|
|
|
if (!data) return [];
|
|
|
|
return [
|
|
{
|
|
title: makeTitle(`SendouQ Match #${data.match.id}`),
|
|
},
|
|
{
|
|
name: "description",
|
|
content: `${joinListToNaturalString(
|
|
data.groupAlpha.members.map((m) => m.username),
|
|
)} vs. ${joinListToNaturalString(
|
|
data.groupBravo.members.map((m) => m.username),
|
|
)}`,
|
|
},
|
|
];
|
|
};
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["q", "tournament", "user"],
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("sendouq"),
|
|
href: SENDOUQ_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|
const matchId = parseParams({
|
|
params,
|
|
schema: qMatchPageParamsSchema,
|
|
}).id;
|
|
const user = await requireUser(request);
|
|
const data = await parseRequestPayload({
|
|
request,
|
|
schema: matchSchema,
|
|
});
|
|
|
|
switch (data._action) {
|
|
case "REPORT_SCORE": {
|
|
const reportWeapons = () => {
|
|
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
|
|
|
const mergedWeapons = mergeReportedWeapons({
|
|
oldWeapons: oldReportedWeapons,
|
|
newWeapons: data.weapons as (ReportedWeapon & {
|
|
mapIndex: number;
|
|
groupMatchMapId: number;
|
|
})[],
|
|
newReportedMapsCount: data.winners.length,
|
|
});
|
|
|
|
sql.transaction(() => {
|
|
deleteReporterWeaponsByMatchId(matchId);
|
|
addReportedWeapons(mergedWeapons);
|
|
})();
|
|
};
|
|
|
|
const match = notFoundIfFalsy(findMatchById(matchId));
|
|
if (match.isLocked) {
|
|
reportWeapons();
|
|
return null;
|
|
}
|
|
|
|
validate(
|
|
!data.adminReport || isMod(user),
|
|
"Only mods can report scores as admin",
|
|
);
|
|
const members = [
|
|
...(await QMatchRepository.findGroupById({
|
|
groupId: match.alphaGroupId,
|
|
}))!.members.map((m) => ({
|
|
...m,
|
|
groupId: match.alphaGroupId,
|
|
})),
|
|
...(await QMatchRepository.findGroupById({
|
|
groupId: match.bravoGroupId,
|
|
}))!.members.map((m) => ({
|
|
...m,
|
|
groupId: match.bravoGroupId,
|
|
})),
|
|
];
|
|
|
|
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
|
|
invariant(
|
|
groupMemberOfId || data.adminReport,
|
|
"User is not a member of any group",
|
|
);
|
|
|
|
const winner = winnersArrayToWinner(data.winners);
|
|
const winnerGroupId =
|
|
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
|
|
const loserGroupId =
|
|
winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId;
|
|
|
|
// when admin reports match gets locked right away
|
|
const compared = data.adminReport
|
|
? "SAME"
|
|
: compareMatchToReportedScores({
|
|
match,
|
|
winners: data.winners,
|
|
newReporterGroupId: groupMemberOfId!,
|
|
previousReporterGroupId: match.reportedByUserId
|
|
? members.find((m) => m.id === match.reportedByUserId)!.groupId
|
|
: undefined,
|
|
});
|
|
|
|
// same group reporting same score, probably by mistake
|
|
if (compared === "DUPLICATE") {
|
|
reportWeapons();
|
|
return null;
|
|
}
|
|
|
|
const matchIsBeingCanceled = data.winners.length === 0;
|
|
|
|
const { newSkills, differences } =
|
|
compared === "SAME" && !matchIsBeingCanceled
|
|
? calculateMatchSkills({
|
|
groupMatchId: match.id,
|
|
winner: (await QMatchRepository.findGroupById({
|
|
groupId: winnerGroupId,
|
|
}))!.members.map((m) => m.id),
|
|
loser: (await QMatchRepository.findGroupById({
|
|
groupId: loserGroupId,
|
|
}))!.members.map((m) => m.id),
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
})
|
|
: { newSkills: null, differences: null };
|
|
|
|
const shouldLockMatchWithoutChangingRecords =
|
|
compared === "SAME" && matchIsBeingCanceled;
|
|
|
|
let clearCaches = false;
|
|
sql.transaction(() => {
|
|
if (
|
|
compared === "FIX_PREVIOUS" ||
|
|
compared === "FIRST_REPORT" ||
|
|
data.adminReport
|
|
) {
|
|
reportScore({
|
|
matchId,
|
|
reportedByUserId: user.id,
|
|
winners: data.winners,
|
|
});
|
|
}
|
|
// own group gets set inactive
|
|
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
|
|
// skills & map/player results only update after both teams have reported
|
|
if (newSkills) {
|
|
addMapResults(
|
|
summarizeMaps({ match, members, winners: data.winners }),
|
|
);
|
|
addPlayerResults(
|
|
summarizePlayerResults({ match, members, winners: data.winners }),
|
|
);
|
|
addSkills({
|
|
skills: newSkills,
|
|
differences,
|
|
groupMatchId: match.id,
|
|
oldMatchMemento: match.memento,
|
|
});
|
|
clearCaches = true;
|
|
}
|
|
if (shouldLockMatchWithoutChangingRecords) {
|
|
addDummySkill(match.id);
|
|
clearCaches = true;
|
|
}
|
|
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
|
|
if (compared === "FIX_PREVIOUS") {
|
|
deleteReporterWeaponsByMatchId(matchId);
|
|
}
|
|
// admin reporting, just set both groups inactive
|
|
if (data.adminReport) {
|
|
setGroupAsInactive(match.alphaGroupId);
|
|
setGroupAsInactive(match.bravoGroupId);
|
|
}
|
|
})();
|
|
|
|
if (clearCaches) {
|
|
// this is kind of useless to do when admin reports since skills don't change
|
|
// but it's not the most common case so it's ok
|
|
try {
|
|
refreshUserSkills(currentOrPreviousSeason(new Date())!.nth);
|
|
} catch (error) {
|
|
logger.warn("Error refreshing user skills", error);
|
|
}
|
|
|
|
refreshStreamsCache();
|
|
}
|
|
|
|
if (compared === "DIFFERENT") {
|
|
return {
|
|
error: matchIsBeingCanceled
|
|
? ("cant-cancel" as const)
|
|
: ("different" as const),
|
|
};
|
|
}
|
|
|
|
// in a different transaction but it's okay
|
|
reportWeapons();
|
|
|
|
if (match.chatCode) {
|
|
const type = (): NonNullable<ChatMessage["type"]> => {
|
|
if (compared === "SAME") {
|
|
return matchIsBeingCanceled
|
|
? "CANCEL_CONFIRMED"
|
|
: "SCORE_CONFIRMED";
|
|
}
|
|
|
|
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
|
|
};
|
|
|
|
NotificationService.notify({
|
|
room: match.chatCode,
|
|
type: type(),
|
|
context: {
|
|
name: user.username,
|
|
},
|
|
});
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "LOOK_AGAIN": {
|
|
const season = currentSeason(new Date());
|
|
validate(season, "Season is not active");
|
|
|
|
const previousGroup = await QMatchRepository.findGroupById({
|
|
groupId: data.previousGroupId,
|
|
});
|
|
validate(previousGroup, "Previous group not found");
|
|
|
|
for (const member of previousGroup.members) {
|
|
const currentGroup = findCurrentGroupByUserId(member.id);
|
|
validate(!currentGroup, "Member is already in a group");
|
|
if (member.id === user.id) {
|
|
validate(
|
|
member.role === "OWNER",
|
|
"You are not the owner of the group",
|
|
);
|
|
}
|
|
}
|
|
|
|
await QRepository.createGroupFromPrevious({
|
|
previousGroupId: data.previousGroupId,
|
|
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
|
|
});
|
|
|
|
throw redirect(SENDOUQ_PREPARING_PAGE);
|
|
}
|
|
case "REPORT_WEAPONS": {
|
|
const match = notFoundIfFalsy(findMatchById(matchId));
|
|
validate(match.reportedAt, "Match has not been reported yet");
|
|
|
|
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
|
|
|
const mergedWeapons = mergeReportedWeapons({
|
|
oldWeapons: oldReportedWeapons,
|
|
newWeapons: data.weapons as (ReportedWeapon & {
|
|
mapIndex: number;
|
|
groupMatchMapId: number;
|
|
})[],
|
|
});
|
|
|
|
sql.transaction(() => {
|
|
deleteReporterWeaponsByMatchId(matchId);
|
|
addReportedWeapons(mergedWeapons);
|
|
})();
|
|
|
|
break;
|
|
}
|
|
case "ADD_PRIVATE_USER_NOTE": {
|
|
await QRepository.upsertPrivateUserNote({
|
|
authorId: user.id,
|
|
sentiment: data.sentiment,
|
|
targetId: data.targetId,
|
|
text: data.comment,
|
|
});
|
|
|
|
throw redirect(sendouQMatchPage(matchId));
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|
const user = await getUserId(request);
|
|
const matchId = parseParams({
|
|
params,
|
|
schema: qMatchPageParamsSchema,
|
|
}).id;
|
|
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
|
|
|
const [groupAlpha, groupBravo] = await Promise.all([
|
|
QMatchRepository.findGroupById({
|
|
groupId: match.alphaGroupId,
|
|
loggedInUserId: user?.id,
|
|
}),
|
|
QMatchRepository.findGroupById({
|
|
groupId: match.bravoGroupId,
|
|
loggedInUserId: user?.id,
|
|
}),
|
|
]);
|
|
invariant(groupAlpha, "Group alpha not found");
|
|
invariant(groupBravo, "Group bravo not found");
|
|
|
|
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
|
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
|
const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user);
|
|
const matchHappenedInTheLastMonth =
|
|
databaseTimestampToDate(match.createdAt).getTime() >
|
|
Date.now() - 30 * 24 * 3600 * 1000;
|
|
|
|
const censoredGroupAlpha = {
|
|
...groupAlpha,
|
|
chatCode: undefined,
|
|
members: groupAlpha.members.map((m) => ({
|
|
...m,
|
|
friendCode:
|
|
isMatchInsider && matchHappenedInTheLastMonth
|
|
? m.friendCode
|
|
: undefined,
|
|
})),
|
|
};
|
|
const censoredGroupBravo = {
|
|
...groupBravo,
|
|
chatCode: undefined,
|
|
members: groupBravo.members.map((m) => ({
|
|
...m,
|
|
friendCode:
|
|
isMatchInsider && matchHappenedInTheLastMonth
|
|
? m.friendCode
|
|
: undefined,
|
|
})),
|
|
};
|
|
const censoredMatch = { ...match, chatCode: undefined };
|
|
|
|
const groupChatCode = () => {
|
|
if (isTeamAlphaMember) return groupAlpha.chatCode;
|
|
if (isTeamBravoMember) return groupBravo.chatCode;
|
|
|
|
return null;
|
|
};
|
|
|
|
const rawReportedWeapons = match.reportedAt
|
|
? reportedWeaponsByMatchId(matchId)
|
|
: null;
|
|
|
|
const banScreen = !match.isLocked
|
|
? await cachified({
|
|
key: `matches-screen-ban-${match.id}`,
|
|
cache,
|
|
async getFreshValue() {
|
|
const noScreenSettings =
|
|
await QMatchRepository.groupMembersNoScreenSettings([
|
|
groupAlpha,
|
|
groupBravo,
|
|
]);
|
|
|
|
return noScreenSettings.some((user) => user.noScreen);
|
|
},
|
|
})
|
|
: null;
|
|
|
|
return {
|
|
match: censoredMatch,
|
|
matchChatCode: isMatchInsider ? match.chatCode : null,
|
|
canPostChatMessages: isTeamAlphaMember || isTeamBravoMember,
|
|
groupChatCode: groupChatCode(),
|
|
groupAlpha: censoredGroupAlpha,
|
|
groupBravo: censoredGroupBravo,
|
|
banScreen,
|
|
groupMemberOf: isTeamAlphaMember
|
|
? ("ALPHA" as const)
|
|
: isTeamBravoMember
|
|
? ("BRAVO" as const)
|
|
: null,
|
|
reportedWeapons: match.reportedAt
|
|
? reportedWeaponsToArrayOfArrays({
|
|
groupAlpha,
|
|
groupBravo,
|
|
mapList: match.mapList,
|
|
reportedWeapons: rawReportedWeapons,
|
|
})
|
|
: null,
|
|
rawReportedWeapons,
|
|
};
|
|
};
|
|
|
|
export default function QMatchPage() {
|
|
const user = useUser();
|
|
const isMounted = useIsMounted();
|
|
const { t, i18n } = useTranslation(["q"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: biome migration
|
|
React.useEffect(() => {
|
|
setShowWeaponsForm(false);
|
|
}, [data.reportedWeapons, data.match.id]);
|
|
|
|
const ownMember =
|
|
data.groupAlpha.members.find((m) => m.id === user?.id) ??
|
|
data.groupBravo.members.find((m) => m.id === user?.id);
|
|
const canReportScore = Boolean(
|
|
!data.match.isLocked && (ownMember || isMod(user)),
|
|
);
|
|
|
|
const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id)
|
|
? data.groupAlpha
|
|
: data.groupBravo.members.some((m) => m.id === user?.id)
|
|
? data.groupBravo
|
|
: null;
|
|
|
|
const ownTeamReported = Boolean(
|
|
data.match.reportedByUserId &&
|
|
ownGroup?.members.some((m) => m.id === data.match.reportedByUserId),
|
|
);
|
|
const showScore =
|
|
data.match.isLocked || (data.match.reportedByUserId && ownGroup);
|
|
|
|
const addingNoteFor = (
|
|
data.groupMemberOf === "ALPHA" ? data.groupAlpha : data.groupBravo
|
|
).members.find((m) => m.id === safeNumberParse(searchParams.get("note")));
|
|
|
|
return (
|
|
<Main className="q-match__container stack xl">
|
|
<AddPrivateNoteDialog
|
|
aboutUser={addingNoteFor}
|
|
close={() => navigate(sendouQMatchPage(data.match.id))}
|
|
/>
|
|
<div className="q-match__header">
|
|
<h2>{t("q:match.header", { number: data.match.id })}</h2>
|
|
<div
|
|
className={clsx("text-xs text-lighter", {
|
|
invisible: !isMounted,
|
|
})}
|
|
>
|
|
{isMounted
|
|
? databaseTimestampToDate(data.match.createdAt).toLocaleString(
|
|
i18n.language,
|
|
{
|
|
day: "numeric",
|
|
month: "numeric",
|
|
year: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
},
|
|
)
|
|
: // reserve place
|
|
"0/0/0 0:00"}
|
|
</div>
|
|
</div>
|
|
{showScore ? (
|
|
<>
|
|
<Score
|
|
reportedAt={data.match.reportedAt!}
|
|
ownTeamReported={ownTeamReported}
|
|
/>
|
|
{ownGroup && ownMember && data.match.reportedAt ? (
|
|
<AfterMatchActions
|
|
ownGroupId={ownGroup.id}
|
|
role={ownMember.role}
|
|
reportedAt={data.match.reportedAt}
|
|
showWeaponsForm={showWeaponsForm}
|
|
setShowWeaponsForm={setShowWeaponsForm}
|
|
key={data.reportedWeapons?.join("")}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{!showWeaponsForm ? (
|
|
<>
|
|
<div className="q-match__teams-container">
|
|
{[data.groupAlpha, data.groupBravo].map((group, i) => {
|
|
const side = i === 0 ? "ALPHA" : "BRAVO";
|
|
const isOwnGroup = data.groupMemberOf === side;
|
|
|
|
const matchHasBeenReported = Boolean(data.match.reportedByUserId);
|
|
const showAddNote =
|
|
data.groupMemberOf === side && matchHasBeenReported;
|
|
|
|
return (
|
|
<div className="stack sm text-lighter text-xs" key={group.id}>
|
|
<div className="stack horizontal justify-between items-center">
|
|
{i === 0 ? "Alpha" : "Bravo"}
|
|
{group.team ? (
|
|
<Link
|
|
to={teamPage(group.team.customUrl)}
|
|
className="stack horizontal items-center xs font-bold"
|
|
>
|
|
{group.team.avatarUrl ? (
|
|
<Avatar
|
|
url={userSubmittedImage(group.team.avatarUrl)}
|
|
size="xxs"
|
|
/>
|
|
) : null}
|
|
{group.team.name}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
<GroupCard
|
|
group={group}
|
|
displayOnly
|
|
hideVc={matchHasBeenReported || !isOwnGroup}
|
|
hideWeapons={matchHasBeenReported}
|
|
showAddNote={showAddNote}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<BottomSection
|
|
canReportScore={canReportScore}
|
|
ownTeamReported={ownTeamReported}
|
|
participatingInTheMatch={Boolean(ownMember)}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function Score({
|
|
reportedAt,
|
|
ownTeamReported,
|
|
}: {
|
|
reportedAt: number;
|
|
ownTeamReported: boolean;
|
|
}) {
|
|
const isMounted = useIsMounted();
|
|
const { t, i18n } = useTranslation(["q"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
const reporter =
|
|
data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ??
|
|
data.groupBravo.members.find((m) => m.id === data.match.reportedByUserId);
|
|
|
|
const score = data.match.mapList.reduce(
|
|
(acc, cur) => {
|
|
if (!cur.winnerGroupId) return acc;
|
|
|
|
if (cur.winnerGroupId === data.match.alphaGroupId) {
|
|
return [acc[0] + 1, acc[1]];
|
|
}
|
|
|
|
return [acc[0], acc[1] + 1];
|
|
},
|
|
[0, 0],
|
|
);
|
|
|
|
if (score[0] === 0 && score[1] === 0) {
|
|
return (
|
|
<div className="stack items-center line-height-tight">
|
|
<div className="text-sm font-bold text-warning">
|
|
{data.match.isLocked
|
|
? t("q:match.canceled")
|
|
: t("q:match.cancelRequested")}
|
|
</div>
|
|
{!data.match.isLocked ? (
|
|
<div className="text-xs text-lighter stack xs items-center text-center">
|
|
{!ownTeamReported ? (
|
|
<DisputePopover />
|
|
) : (
|
|
t("q:match.cancelPendingConfirmation")
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="stack items-center line-height-tight">
|
|
<div className="text-lg font-bold">{score.join(" - ")}</div>
|
|
{data.match.isLocked ? (
|
|
<div
|
|
className={clsx("text-xs text-lighter", { invisible: !isMounted })}
|
|
>
|
|
{t("q:match.reportedBy", { name: reporter?.username ?? "admin" })}{" "}
|
|
{isMounted
|
|
? databaseTimestampToDate(reportedAt).toLocaleString(
|
|
i18n.language,
|
|
{
|
|
day: "numeric",
|
|
month: "numeric",
|
|
year: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
},
|
|
)
|
|
: ""}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-lighter stack xs items-center text-center">
|
|
{t("q:match.spInfo")} {!ownTeamReported ? <DisputePopover /> : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DisputePopover() {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
return (
|
|
<SendouPopover
|
|
popoverClassName="text-main-forced"
|
|
trigger={
|
|
<SendouButton
|
|
size="small"
|
|
variant="minimal-destructive"
|
|
className="mt-2"
|
|
>
|
|
{t("q:match.dispute.button")}
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<p>{t("q:match.dispute.p1")}</p>
|
|
<p className="mt-2">{t("q:match.dispute.p2")}</p>
|
|
</SendouPopover>
|
|
);
|
|
}
|
|
|
|
function AfterMatchActions({
|
|
ownGroupId,
|
|
role,
|
|
reportedAt,
|
|
showWeaponsForm,
|
|
setShowWeaponsForm,
|
|
}: {
|
|
ownGroupId: number;
|
|
role: GroupMember["role"];
|
|
reportedAt: number;
|
|
showWeaponsForm: boolean;
|
|
setShowWeaponsForm: (show: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
const lookAgainFetcher = useFetcher();
|
|
|
|
const wasReportedInTheLastHour =
|
|
databaseTimestampToDate(reportedAt).getTime() > Date.now() - 3600 * 1000;
|
|
|
|
const season = currentSeason(new Date());
|
|
const showLookAgain = role === "OWNER" && wasReportedInTheLastHour && season;
|
|
|
|
const wasReportedInTheLastWeek =
|
|
databaseTimestampToDate(reportedAt).getTime() >
|
|
Date.now() - 7 * 24 * 3600 * 1000;
|
|
const showWeaponsFormButton =
|
|
wasReportedInTheLastWeek && data.match.mapList[0].winnerGroupId;
|
|
|
|
return (
|
|
<div className="stack lg">
|
|
<lookAgainFetcher.Form
|
|
method="post"
|
|
className="stack horizontal justify-center md flex-wrap"
|
|
>
|
|
<input type="hidden" name="previousGroupId" value={ownGroupId} />
|
|
{showLookAgain ? (
|
|
<SubmitButton
|
|
icon={<RefreshArrowsIcon />}
|
|
state={lookAgainFetcher.state}
|
|
_action="LOOK_AGAIN"
|
|
>
|
|
{t("q:match.actions.lookAgain")}
|
|
</SubmitButton>
|
|
) : null}
|
|
{showWeaponsFormButton ? (
|
|
<Button
|
|
icon={<ArchiveBoxIcon />}
|
|
onClick={() => setShowWeaponsForm(!showWeaponsForm)}
|
|
variant={showWeaponsForm ? "destructive" : undefined}
|
|
>
|
|
{showWeaponsForm
|
|
? t("q:match.actions.stopReportingWeapons")
|
|
: t("q:match.actions.reportWeapons")}
|
|
</Button>
|
|
) : null}
|
|
</lookAgainFetcher.Form>
|
|
{showWeaponsForm ? <ReportWeaponsForm /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReportWeaponsForm() {
|
|
const { t } = useTranslation(["q", "user"]);
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const weaponsFetcher = useFetcher();
|
|
|
|
const [weaponsUsage, setWeaponsUsage] = React.useState<
|
|
ReportedWeaponForMerging[]
|
|
>(data.rawReportedWeapons ?? []);
|
|
const [reportingMode, setReportingMode] = React.useState<
|
|
"ALL" | "MYSELF" | "MY_TEAM"
|
|
>("MYSELF");
|
|
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
|
|
useRecentlyReportedWeapons();
|
|
|
|
const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId);
|
|
const winners = playedMaps.map((m) =>
|
|
m.winnerGroupId === data.match.alphaGroupId ? "ALPHA" : "BRAVO",
|
|
);
|
|
|
|
const handleCopyWeaponsFromPreviousMap =
|
|
({
|
|
mapIndex,
|
|
groupMatchMapId,
|
|
}: {
|
|
mapIndex: number;
|
|
groupMatchMapId: number;
|
|
}) =>
|
|
() => {
|
|
setWeaponsUsage((val) => {
|
|
const previousWeapons = val.filter(
|
|
(reportedWeapon) => reportedWeapon.mapIndex === mapIndex - 1,
|
|
);
|
|
|
|
return [
|
|
...val.filter(
|
|
(reportedWeapon) => reportedWeapon.mapIndex !== mapIndex,
|
|
),
|
|
...previousWeapons.map((reportedWeapon) => ({
|
|
...reportedWeapon,
|
|
mapIndex,
|
|
groupMatchMapId,
|
|
})),
|
|
];
|
|
});
|
|
};
|
|
|
|
const playersToReport = () => {
|
|
const allPlayers = [...data.groupAlpha.members, ...data.groupBravo.members];
|
|
|
|
switch (reportingMode) {
|
|
case "ALL": {
|
|
return allPlayers;
|
|
}
|
|
case "MYSELF": {
|
|
const me = allPlayers.find((m) => m.id === user?.id);
|
|
invariant(me, "User not found");
|
|
|
|
return [me];
|
|
}
|
|
case "MY_TEAM": {
|
|
return data.groupMemberOf === "ALPHA"
|
|
? data.groupAlpha.members
|
|
: data.groupBravo.members;
|
|
}
|
|
default:
|
|
assertUnreachable(reportingMode);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<weaponsFetcher.Form method="post" className="stack lg">
|
|
<input
|
|
type="hidden"
|
|
name="weapons"
|
|
value={JSON.stringify(weaponsUsage)}
|
|
/>
|
|
<div className="stack horizontal sm justify-between w-max mx-auto">
|
|
<h3 className="text-md">{t("q:match.report.whoToReport")}</h3>
|
|
<label className="stack horizontal xs items-center mb-0">
|
|
{t("q:match.report.whoToReport.me")}
|
|
<input
|
|
type="radio"
|
|
checked={reportingMode === "MYSELF"}
|
|
onChange={() => setReportingMode("MYSELF")}
|
|
/>
|
|
</label>
|
|
<label className="stack horizontal xs items-center mb-0">
|
|
{t("q:match.report.whoToReport.myTeam")}
|
|
<input
|
|
type="radio"
|
|
checked={reportingMode === "MY_TEAM"}
|
|
onChange={() => setReportingMode("MY_TEAM")}
|
|
/>
|
|
</label>
|
|
<label className="stack horizontal xs items-center mb-0">
|
|
{t("q:match.report.whoToReport.everyone")}
|
|
<input
|
|
type="radio"
|
|
checked={reportingMode === "ALL"}
|
|
onChange={() => setReportingMode("ALL")}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="stack md mx-auto">
|
|
{playedMaps.map((map, i) => {
|
|
const groupMatchMapId = map.id;
|
|
|
|
return (
|
|
<div key={map.stageId} className="stack md">
|
|
<MapListMap
|
|
canReportScore={false}
|
|
i={i}
|
|
map={map}
|
|
winners={winners}
|
|
showReportedOwnWeapon={false}
|
|
/>
|
|
{i !== 0 && reportingMode !== "MYSELF" ? (
|
|
<Button
|
|
size="tiny"
|
|
variant="outlined"
|
|
className="self-center"
|
|
onClick={handleCopyWeaponsFromPreviousMap({
|
|
groupMatchMapId,
|
|
mapIndex: i,
|
|
})}
|
|
>
|
|
{t("q:match.report.copyWeapons")}
|
|
</Button>
|
|
) : null}
|
|
<div className="stack sm">
|
|
{playersToReport().map((member, j) => {
|
|
const weaponSplId =
|
|
weaponsUsage.find(
|
|
(w) =>
|
|
w.groupMatchMapId === groupMatchMapId &&
|
|
w.userId === member.id,
|
|
)?.weaponSplId ?? null;
|
|
|
|
return (
|
|
<React.Fragment key={member.id}>
|
|
{j === 0 && reportingMode === "ALL" ? (
|
|
<Divider className="text-sm">
|
|
{t("q:match.sides.alpha")}
|
|
</Divider>
|
|
) : null}
|
|
{j === FULL_GROUP_SIZE && reportingMode === "ALL" ? (
|
|
<Divider className="text-sm">
|
|
{t("q:match.sides.bravo")}
|
|
</Divider>
|
|
) : null}
|
|
<div
|
|
key={member.id}
|
|
className="stack horizontal sm justify-between items-center flex-wrap"
|
|
>
|
|
<div className="q-match__report__user-name-container">
|
|
<Avatar user={member} size="xxs" />{" "}
|
|
{member.inGameName ? (
|
|
<>
|
|
<span className="text-lighter font-semi-bold">
|
|
{t("user:ign.short")}:
|
|
</span>{" "}
|
|
{inGameNameWithoutDiscriminator(
|
|
member.inGameName,
|
|
)}
|
|
</>
|
|
) : (
|
|
member.username
|
|
)}
|
|
</div>
|
|
<div className="stack horizontal sm items-center">
|
|
<WeaponImage
|
|
weaponSplId={weaponSplId ?? 0}
|
|
variant="badge"
|
|
width={32}
|
|
className={clsx("ml-auto", {
|
|
invisible: typeof weaponSplId !== "number",
|
|
})}
|
|
/>
|
|
<WeaponCombobox
|
|
inputName="weapon"
|
|
value={weaponSplId}
|
|
quickSelectWeaponIds={recentlyReportedWeapons}
|
|
onChange={(weapon) => {
|
|
if (!weapon) return;
|
|
|
|
const weaponSplId = Number(
|
|
weapon.value,
|
|
) as MainWeaponId;
|
|
|
|
addRecentlyReportedWeapon(weaponSplId);
|
|
|
|
setWeaponsUsage((val) => {
|
|
const result = val.filter(
|
|
(reportedWeapon) =>
|
|
reportedWeapon.groupMatchMapId !==
|
|
groupMatchMapId ||
|
|
reportedWeapon.userId !== member.id,
|
|
);
|
|
|
|
result.push({
|
|
weaponSplId,
|
|
mapIndex: i,
|
|
groupMatchMapId,
|
|
userId: member.id,
|
|
});
|
|
|
|
return result;
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{weaponsUsage.flat().some((val) => val === null) ? (
|
|
<div className="text-sm text-center text-warning font-semi-bold">
|
|
{t("q:match.report.error")}
|
|
</div>
|
|
) : (
|
|
<div className="stack items-center">
|
|
<SubmitButton _action="REPORT_WEAPONS">
|
|
{t("q:match.report.submit")}
|
|
</SubmitButton>
|
|
</div>
|
|
)}
|
|
</weaponsFetcher.Form>
|
|
);
|
|
}
|
|
|
|
function BottomSection({
|
|
canReportScore,
|
|
ownTeamReported,
|
|
participatingInTheMatch,
|
|
}: {
|
|
canReportScore: boolean;
|
|
ownTeamReported: boolean;
|
|
participatingInTheMatch: boolean;
|
|
}) {
|
|
const { t } = useTranslation(["q", "common"]);
|
|
const { width } = useWindowSize();
|
|
const isMobile = width < 750;
|
|
const isMounted = useIsMounted();
|
|
const [isReportingWeapons, setIsReportingWeapons] = React.useState(false);
|
|
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const submitScoreFetcher = useFetcher<typeof action>();
|
|
const cancelFetcher = useFetcher<typeof action>();
|
|
|
|
const chatUsers = React.useMemo(() => {
|
|
return Object.fromEntries(
|
|
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
|
|
m.id,
|
|
m,
|
|
]),
|
|
);
|
|
}, [data]);
|
|
|
|
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
|
const [chatVisible, setChatVisible] = React.useState(false);
|
|
|
|
const onNewMessage = React.useCallback(() => {
|
|
setUnseenMessages((msg) => msg + 1);
|
|
}, []);
|
|
|
|
const chatRooms = React.useMemo(() => {
|
|
return [
|
|
data.matchChatCode ? { code: data.matchChatCode, label: "Match" } : null,
|
|
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
|
|
].filter(Boolean) as ChatProps["rooms"];
|
|
}, [data.matchChatCode, data.groupChatCode]);
|
|
|
|
const ownWeaponsReported = data.rawReportedWeapons?.some(
|
|
(rw) => rw.userId === user?.id,
|
|
);
|
|
|
|
// revalidates: false when we don't want the user to lose the weapons
|
|
// they are reporting when the match gets suddenly locked
|
|
const chat = useChat({
|
|
rooms: chatRooms,
|
|
onNewMessage,
|
|
revalidates: ownWeaponsReported || !isReportingWeapons,
|
|
});
|
|
|
|
const onChatMount = React.useCallback(() => {
|
|
setChatVisible(true);
|
|
}, []);
|
|
|
|
const onChatUnmount = React.useCallback(() => {
|
|
setChatVisible(false);
|
|
setUnseenMessages(0);
|
|
}, []);
|
|
|
|
const unseenMessages = chatVisible ? 0 : _unseenMessages;
|
|
|
|
const showMid =
|
|
!data.match.isLocked && (participatingInTheMatch || isMod(user));
|
|
|
|
const poolCode = () => {
|
|
const stringId = String(data.match.id);
|
|
const lastDigit = stringId[stringId.length - 1];
|
|
|
|
return `SQ${lastDigit}`;
|
|
};
|
|
|
|
if (!isMounted) return null;
|
|
|
|
const chatElement = (
|
|
<Chat
|
|
chat={chat}
|
|
onMount={onChatMount}
|
|
onUnmount={onChatUnmount}
|
|
users={chatUsers}
|
|
rooms={chatRooms}
|
|
disabled={!data.canPostChatMessages}
|
|
/>
|
|
);
|
|
|
|
const mapListElement = (
|
|
<MapList
|
|
key={data.match.id}
|
|
canReportScore={canReportScore}
|
|
isResubmission={ownTeamReported}
|
|
fetcher={submitScoreFetcher}
|
|
setIsReportingWeapons={setIsReportingWeapons}
|
|
/>
|
|
);
|
|
|
|
const roomJoiningInfoElement = (
|
|
<div
|
|
className={clsx("q-match__pool-pass-container", { "mx-auto": !isMobile })}
|
|
>
|
|
<InfoWithHeader header={t("q:match.pool")} value={poolCode()} />
|
|
<InfoWithHeader
|
|
header={t("q:match.password.short")}
|
|
value={resolveRoomPass(data.match.id)}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const rulesButtonElement = (
|
|
<LinkButton
|
|
to={SENDOUQ_RULES_PAGE}
|
|
variant="outlined"
|
|
size="tiny"
|
|
icon={<ScaleIcon />}
|
|
>
|
|
{t("q:front.nav.rules.title")}
|
|
</LinkButton>
|
|
);
|
|
|
|
const helpdeskButtonElement = (
|
|
<LinkButton
|
|
isExternal
|
|
to={SENDOU_INK_DISCORD_URL}
|
|
variant="outlined"
|
|
size="tiny"
|
|
icon={<DiscordIcon />}
|
|
>
|
|
{t("q:match.helpdesk")}
|
|
</LinkButton>
|
|
);
|
|
|
|
const cancelMatchElement =
|
|
canReportScore && !data.match.isLocked ? (
|
|
<FormWithConfirm
|
|
dialogHeading={t("q:match.cancelMatch.confirm")}
|
|
fields={[
|
|
["_action", "REPORT_SCORE"],
|
|
["winners", "[]"],
|
|
...(!data.groupMemberOf ? [["adminReport", "on"] as const] : []),
|
|
]}
|
|
deleteButtonText={t("common:actions.cancel")}
|
|
cancelButtonText={t("common:actions.nevermind")}
|
|
fetcher={cancelFetcher}
|
|
>
|
|
<Button
|
|
variant="minimal-destructive"
|
|
size="tiny"
|
|
type="submit"
|
|
disabled={ownTeamReported && !data.match.mapList[0].winnerGroupId}
|
|
className="build__small-text mt-4"
|
|
>
|
|
{t("q:match.cancelMatch")}
|
|
</Button>
|
|
</FormWithConfirm>
|
|
) : null;
|
|
|
|
const screenLegalityInfoElement =
|
|
data.banScreen !== null ? (
|
|
<ScreenLegalityInfo ban={data.banScreen} />
|
|
) : null;
|
|
|
|
const chatHidden = chatRooms.length === 0;
|
|
|
|
if (!showMid && chatHidden) {
|
|
return mapListElement;
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<div className="stack lg">
|
|
<div className="stack horizontal lg items-center justify-center">
|
|
{roomJoiningInfoElement}
|
|
<div className="stack md">
|
|
{screenLegalityInfoElement}
|
|
{rulesButtonElement}
|
|
{helpdeskButtonElement}
|
|
{cancelMatchElement}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<NewTabs
|
|
sticky
|
|
tabs={[
|
|
{
|
|
label: t("q:looking.columns.chat"),
|
|
number: unseenMessages,
|
|
hidden: chatHidden,
|
|
},
|
|
{
|
|
label: t("q:match.tabs.reportScore"),
|
|
},
|
|
]}
|
|
disappearing
|
|
content={[
|
|
{
|
|
key: "chat",
|
|
hidden: chatHidden,
|
|
element: chatElement,
|
|
},
|
|
{
|
|
key: "report",
|
|
element: mapListElement,
|
|
unmount: false,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="q-match__map-list-chat-container">
|
|
{mapListElement}
|
|
<div
|
|
className={clsx("q-match__bottom-mid-section", {
|
|
invisible: !showMid,
|
|
})}
|
|
>
|
|
<div className="stack md">
|
|
{roomJoiningInfoElement}
|
|
{rulesButtonElement}
|
|
{helpdeskButtonElement}
|
|
{screenLegalityInfoElement}
|
|
{cancelMatchElement}
|
|
</div>
|
|
</div>
|
|
<div className="q-match__chat-container">
|
|
{chatRooms.length > 0 ? chatElement : null}
|
|
</div>
|
|
</div>
|
|
{cancelFetcher.data?.error === "cant-cancel" ? (
|
|
<div className="text-xs text-warning font-semi-bold text-center">
|
|
{t("q:match.errors.cantCancel")}
|
|
</div>
|
|
) : null}
|
|
{submitScoreFetcher.data?.error === "different" ? (
|
|
<div className="text-xs text-warning font-semi-bold text-center">
|
|
{t("q:match.errors.different")}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ScreenLegalityInfo({ ban }: { ban: boolean }) {
|
|
const { t } = useTranslation(["q", "weapons"]);
|
|
|
|
return (
|
|
<div className="q-match__screen-legality">
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton
|
|
variant="minimal"
|
|
size="small"
|
|
className="q-match__screen-legality__button"
|
|
>
|
|
<Alert variation={ban ? "ERROR" : "SUCCESS"}>
|
|
<div className="stack xs horizontal items-center">
|
|
<Image
|
|
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
|
|
width={30}
|
|
height={30}
|
|
alt={t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)}
|
|
/>
|
|
</div>
|
|
</Alert>
|
|
</SendouButton>
|
|
}
|
|
>
|
|
{ban
|
|
? t("q:match.screen.ban", {
|
|
special: t("weapons:SPECIAL_19"),
|
|
})
|
|
: t("q:match.screen.allowed", {
|
|
special: t("weapons:SPECIAL_19"),
|
|
})}
|
|
</SendouPopover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoWithHeader({ header, value }: { header: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<div className="q-match__info__header">{header}</div>
|
|
<div className="q-match__info__value">{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapList({
|
|
canReportScore,
|
|
isResubmission,
|
|
fetcher,
|
|
setIsReportingWeapons,
|
|
}: {
|
|
canReportScore: boolean;
|
|
isResubmission: boolean;
|
|
fetcher: FetcherWithComponents<any>;
|
|
setIsReportingWeapons: (val: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const [adminToggleChecked, setAdminToggleChecked] = React.useState(false);
|
|
const [ownWeaponsUsage, setOwnWeaponsUsage] = React.useState<
|
|
ReportedWeaponForMerging[]
|
|
>([]);
|
|
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
|
|
useRecentlyReportedWeapons();
|
|
|
|
const previouslyReportedWinners = isResubmission
|
|
? data.match.mapList
|
|
.filter((m) => m.winnerGroupId)
|
|
.map((m) =>
|
|
m.winnerGroupId === data.groupAlpha.id ? "ALPHA" : "BRAVO",
|
|
)
|
|
: [];
|
|
const [winners, setWinners] = React.useState<("ALPHA" | "BRAVO")[]>(
|
|
previouslyReportedWinners,
|
|
);
|
|
|
|
const newScoresAreDifferent =
|
|
!previouslyReportedWinners ||
|
|
previouslyReportedWinners.length !== winners.length ||
|
|
previouslyReportedWinners.some((w, i) => w !== winners[i]);
|
|
const scoreCanBeReported =
|
|
Boolean(matchEndedAtIndex(winners)) &&
|
|
!data.match.isLocked &&
|
|
newScoresAreDifferent;
|
|
const ownWeaponReported = data.rawReportedWeapons?.some(
|
|
(reportedWeapon) => reportedWeapon.userId === user?.id,
|
|
);
|
|
|
|
return (
|
|
<fetcher.Form method="post">
|
|
<input type="hidden" name="winners" value={JSON.stringify(winners)} />
|
|
<input
|
|
type="hidden"
|
|
name="weapons"
|
|
value={JSON.stringify(ownWeaponsUsage)}
|
|
/>
|
|
<Flipper flipKey={winners.join("")}>
|
|
<div className="stack md w-max mx-auto">
|
|
{data.match.mapList.map((map, i) => {
|
|
return (
|
|
<MapListMap
|
|
key={map.stageId}
|
|
canReportScore={canReportScore}
|
|
i={i}
|
|
map={map}
|
|
winners={winners}
|
|
setWinners={setWinners}
|
|
weapons={data.reportedWeapons?.[i]}
|
|
showReportedOwnWeapon={!ownWeaponReported}
|
|
recentlyReportedWeapons={recentlyReportedWeapons}
|
|
addRecentlyReportedWeapon={addRecentlyReportedWeapon}
|
|
ownWeapon={
|
|
ownWeaponsUsage.find((w) => w.mapIndex === i)?.weaponSplId
|
|
}
|
|
onOwnWeaponSelected={(newReportedWeapon) => {
|
|
if (!newReportedWeapon) return;
|
|
|
|
setIsReportingWeapons(true);
|
|
|
|
setOwnWeaponsUsage((val) => {
|
|
const result = val.filter(
|
|
(reportedWeapon) =>
|
|
reportedWeapon.groupMatchMapId !==
|
|
newReportedWeapon.groupMatchMapId,
|
|
);
|
|
|
|
if (typeof newReportedWeapon.weaponSplId === "number") {
|
|
result.push(newReportedWeapon);
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</Flipper>
|
|
{scoreCanBeReported && isMod(user) ? (
|
|
<div className="stack sm horizontal items-center text-sm font-semi-bold">
|
|
<SendouSwitch
|
|
name="adminReport"
|
|
isSelected={adminToggleChecked}
|
|
onChange={setAdminToggleChecked}
|
|
/>
|
|
Report as admin
|
|
</div>
|
|
) : null}
|
|
{scoreCanBeReported ? (
|
|
<div className="stack md items-center mt-4">
|
|
<ResultSummary winners={winners} />
|
|
<SubmitButton _action="REPORT_SCORE" state={fetcher.state}>
|
|
{isResubmission
|
|
? t("q:match.submitScores.adjusted")
|
|
: t("q:match.submitScores")}
|
|
</SubmitButton>
|
|
</div>
|
|
) : null}
|
|
</fetcher.Form>
|
|
);
|
|
}
|
|
|
|
function MapListMap({
|
|
i,
|
|
map,
|
|
winners,
|
|
setWinners,
|
|
canReportScore,
|
|
weapons,
|
|
ownWeapon,
|
|
onOwnWeaponSelected,
|
|
showReportedOwnWeapon,
|
|
recentlyReportedWeapons,
|
|
addRecentlyReportedWeapon,
|
|
}: {
|
|
i: number;
|
|
map: Unpacked<SerializeFrom<typeof loader>["match"]["mapList"]>;
|
|
winners: ("ALPHA" | "BRAVO")[];
|
|
setWinners?: (winners: ("ALPHA" | "BRAVO")[]) => void;
|
|
canReportScore: boolean;
|
|
weapons?: (MainWeaponId | null)[] | null;
|
|
ownWeapon?: MainWeaponId | null;
|
|
onOwnWeaponSelected?: (weapon: ReportedWeaponForMerging | null) => void;
|
|
showReportedOwnWeapon: boolean;
|
|
recentlyReportedWeapons?: MainWeaponId[];
|
|
addRecentlyReportedWeapon?: (weapon: MainWeaponId) => void;
|
|
}) {
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation(["q", "game-misc", "tournament", "weapons"]);
|
|
|
|
const handleReportScore = (i: number, side: "ALPHA" | "BRAVO") => () => {
|
|
const newWinners = [...winners];
|
|
newWinners[i] = side;
|
|
|
|
// delete any scores that would have been after set ended (can happen when they go back to edit previously reported scores)
|
|
|
|
const matchEndedAt = matchEndedAtIndex(newWinners);
|
|
|
|
if (matchEndedAt) {
|
|
newWinners.splice(matchEndedAt + 1);
|
|
}
|
|
|
|
setWinners?.(newWinners);
|
|
};
|
|
|
|
const scoreCanBeReported =
|
|
Boolean(matchEndedAtIndex(winners)) && !data.match.isLocked;
|
|
const showWinnerReportRow = (i: number) => {
|
|
if (!canReportScore) return false;
|
|
|
|
if (i === 0) return true;
|
|
|
|
if (scoreCanBeReported && !winners[i]) return false;
|
|
|
|
const previous = winners[i - 1];
|
|
return Boolean(previous);
|
|
};
|
|
|
|
const winningInfoText = (winnerId: number | null) => {
|
|
if (!data.match.isLocked) return null;
|
|
|
|
if (!winnerId)
|
|
return (
|
|
<>
|
|
• <i>{t("q:match.results.unplayed")}</i>
|
|
</>
|
|
);
|
|
|
|
const winnerSide =
|
|
winnerId === data.match.alphaGroupId
|
|
? t("q:match.sides.alpha")
|
|
: t("q:match.sides.bravo");
|
|
|
|
return <>• {t("q:match.won", { side: winnerSide })}</>;
|
|
};
|
|
|
|
const relativeSideText = (side: "ALPHA" | "BRAVO") => {
|
|
if (!data.groupMemberOf) return "";
|
|
|
|
return data.groupMemberOf === side ? " (us)" : " (them)";
|
|
};
|
|
|
|
const modePreferences = data.match.memento?.modePreferences?.[map.mode];
|
|
|
|
const userIdToName = (userId: number) => {
|
|
const member = [
|
|
...data.groupAlpha.members,
|
|
...data.groupBravo.members,
|
|
].find((m) => m.id === userId);
|
|
|
|
return member?.username ?? "";
|
|
};
|
|
|
|
return (
|
|
<div key={map.stageId} className="stack xs">
|
|
<Flipped flipId={map.stageId}>
|
|
<div className="stack sm horizontal items-center">
|
|
<StageImage stageId={map.stageId} width={64} className="rounded-sm" />
|
|
<div>
|
|
<div className="text-sm stack horizontal xs items-center">
|
|
{i + 1}){" "}
|
|
{modePreferences ? (
|
|
<SendouPopover
|
|
popoverClassName="text-main-forced"
|
|
trigger={
|
|
<SendouButton className="q-match__mode-popover-button">
|
|
<ModeImage mode={map.mode} size={18} />
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<div className="text-md text-lighter mb-2 line-height-very-tight">
|
|
{t(`game-misc:MODE_LONG_${map.mode}`)}
|
|
</div>
|
|
{modePreferences.map(({ userId, preference }) => {
|
|
return (
|
|
<div
|
|
key={userId}
|
|
className="stack horizontal items-center xs"
|
|
>
|
|
<img
|
|
src={preferenceEmojiUrl(preference)}
|
|
className="q-settings__radio__emoji"
|
|
width={18}
|
|
alt={`${preference} emoji`}
|
|
/>
|
|
{userIdToName(userId)}
|
|
</div>
|
|
);
|
|
})}
|
|
</SendouPopover>
|
|
) : (
|
|
<ModeImage mode={map.mode} size={18} />
|
|
)}{" "}
|
|
{t(`game-misc:STAGE_${map.stageId}`)}
|
|
</div>
|
|
<div className="text-lighter text-xs stack xxs horizontal">
|
|
<MapListMapPickInfo i={i} map={map} />{" "}
|
|
{winningInfoText(map.winnerGroupId)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Flipped>
|
|
{weapons && map.winnerGroupId && !showReportedOwnWeapon ? (
|
|
<div className="stack sm horizontal">
|
|
{weapons.map((weaponSplId, i) => {
|
|
return (
|
|
<React.Fragment key={i}>
|
|
{typeof weaponSplId === "number" ? (
|
|
<WeaponImage
|
|
weaponSplId={weaponSplId}
|
|
size={30}
|
|
variant="badge"
|
|
/>
|
|
) : (
|
|
<div className="w-4 font-semi-bold stack items-center justify-center">
|
|
?
|
|
</div>
|
|
)}
|
|
{i === 3 ? <div className="w-4" /> : null}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
{showWinnerReportRow(i) ? (
|
|
<Flipped
|
|
flipId={`${map.stageId}-report`}
|
|
onAppear={async (el: HTMLElement) => {
|
|
await animate(el, [{ opacity: 0 }, { opacity: 1 }], {
|
|
duration: 300,
|
|
});
|
|
el.style.opacity = "1";
|
|
}}
|
|
>
|
|
<div className="q-match__report-section">
|
|
<label className="mb-0 text-theme-secondary">
|
|
{t("q:match.report.winnerLabel")}
|
|
</label>
|
|
<div className="stack items-center">
|
|
<div
|
|
className={clsx("q-match__result-dot", {
|
|
"q-match__result-dot__won": winners[i] === data.groupMemberOf,
|
|
"q-match__result-dot__lost":
|
|
winners[i] && winners[i] !== data.groupMemberOf,
|
|
})}
|
|
/>
|
|
</div>
|
|
<div className="stack sm horizontal items-center">
|
|
<div className="stack sm horizontal items-center font-semi-bold">
|
|
<input
|
|
type="radio"
|
|
name={`winner-${i}`}
|
|
value="alpha"
|
|
id={`alpha-${i}`}
|
|
checked={winners[i] === "ALPHA"}
|
|
onChange={handleReportScore(i, "ALPHA")}
|
|
/>
|
|
<label className="mb-0" htmlFor={`alpha-${i}`}>
|
|
{`${t("q:match.sides.alpha")}${relativeSideText("ALPHA")}`}
|
|
</label>
|
|
</div>
|
|
<div className="stack sm horizontal items-center font-semi-bold">
|
|
<input
|
|
type="radio"
|
|
name={`winner-${i}`}
|
|
value="bravo"
|
|
id={`bravo-${i}`}
|
|
checked={winners[i] === "BRAVO"}
|
|
onChange={handleReportScore(i, "BRAVO")}
|
|
/>
|
|
<label className="mb-0" htmlFor={`bravo-${i}`}>
|
|
{`${t("q:match.sides.bravo")}${relativeSideText("BRAVO")}`}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{showReportedOwnWeapon && onOwnWeaponSelected ? (
|
|
<>
|
|
<label className="mb-0 text-theme-secondary">
|
|
{t("q:match.report.weaponLabel")}
|
|
</label>
|
|
<div
|
|
className={clsx({ invisible: typeof ownWeapon !== "number" })}
|
|
>
|
|
{typeof ownWeapon === "number" ? (
|
|
<WeaponImage
|
|
weaponSplId={ownWeapon}
|
|
variant="badge"
|
|
size={36}
|
|
/>
|
|
) : (
|
|
<WeaponImage
|
|
weaponSplId={0}
|
|
variant="badge"
|
|
size={36}
|
|
className="invisible"
|
|
/>
|
|
)}
|
|
</div>
|
|
{typeof ownWeapon === "number" ? (
|
|
<div className="font-bold stack sm horizontal">
|
|
{t(`weapons:MAIN_${ownWeapon}`)}
|
|
<Button
|
|
size="tiny"
|
|
icon={<CrossIcon />}
|
|
variant="minimal-destructive"
|
|
onClick={() => {
|
|
const userId = user!.id;
|
|
const groupMatchMapId = map.id;
|
|
|
|
onOwnWeaponSelected({
|
|
mapIndex: i,
|
|
groupMatchMapId,
|
|
userId,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<WeaponCombobox
|
|
inputName="weapon"
|
|
quickSelectWeaponIds={recentlyReportedWeapons}
|
|
onChange={(weapon) => {
|
|
const userId = user!.id;
|
|
const groupMatchMapId = map.id;
|
|
|
|
const weaponSplId = Number(weapon?.value) as MainWeaponId;
|
|
|
|
addRecentlyReportedWeapon?.(weaponSplId);
|
|
|
|
onOwnWeaponSelected(
|
|
weapon
|
|
? {
|
|
weaponSplId,
|
|
mapIndex: i,
|
|
groupMatchMapId,
|
|
userId,
|
|
}
|
|
: null,
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</Flipped>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapListMapPickInfo({
|
|
i,
|
|
map,
|
|
}: {
|
|
i: number;
|
|
map: Unpacked<SerializeFrom<typeof loader>["match"]["mapList"]>;
|
|
}) {
|
|
const data = useLoaderData<typeof loader>();
|
|
const { t } = useTranslation(["q", "game-misc", "tournament"]);
|
|
|
|
const pickInfo = (source: string) => {
|
|
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
|
|
if (source === "BOTH") return t("tournament:pickInfo.both");
|
|
if (source === "DEFAULT") return t("tournament:pickInfo.default");
|
|
|
|
if (source === String(data.match.alphaGroupId)) {
|
|
return t("tournament:pickInfo.team.specific", {
|
|
team: t("q:match.sides.alpha"),
|
|
});
|
|
}
|
|
|
|
return t("tournament:pickInfo.team.specific", {
|
|
team: t("q:match.sides.bravo"),
|
|
});
|
|
};
|
|
|
|
const userIdToUser = (userId: number) => {
|
|
const member = [
|
|
...data.groupAlpha.members,
|
|
...data.groupBravo.members,
|
|
].find((m) => m.id === userId);
|
|
|
|
return member;
|
|
};
|
|
|
|
const sourcePoolMemberIds = () => {
|
|
const result: number[] = [];
|
|
|
|
if (!data.match.memento?.pools) return result;
|
|
|
|
const pickerGroups = [data.groupAlpha, data.groupBravo].filter(
|
|
(g) => map.source === "BOTH" || String(g.id) === map.source,
|
|
);
|
|
if (pickerGroups.length === 0) return result;
|
|
|
|
for (const pickerGroup of pickerGroups) {
|
|
for (const { userId, pool } of data.match.memento.pools) {
|
|
if (!pickerGroup.members.some((m) => m.id === userId)) {
|
|
continue;
|
|
}
|
|
|
|
const modePool = pool.find((p) => p.mode === map.mode);
|
|
if (modePool?.stages.includes(map.stageId)) {
|
|
result.push(userId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const mapPreferences = data.match.memento?.mapPreferences?.[i];
|
|
const showPopover = () => {
|
|
// legacy preference system (season 2)
|
|
if (mapPreferences && mapPreferences.length > 0) return true;
|
|
|
|
return sourcePoolMemberIds().length > 0;
|
|
};
|
|
|
|
if (showPopover()) {
|
|
return (
|
|
<SendouPopover
|
|
popoverClassName="text-main-forced"
|
|
trigger={
|
|
<SendouButton className="q-match__stage-popover-button">
|
|
{pickInfo(map.source)}
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<div className="text-md text-center text-lighter mb-2 line-height-very-tight">
|
|
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
|
|
{t(`game-misc:STAGE_${map.stageId}`)}
|
|
</div>
|
|
{sourcePoolMemberIds().length > 0 ? (
|
|
<div className="stack sm">
|
|
{sourcePoolMemberIds().map((userId) => {
|
|
const user = userIdToUser(userId);
|
|
return (
|
|
<div
|
|
key={userId}
|
|
className="stack sm horizontal items-center xs"
|
|
>
|
|
<Avatar user={user} size="xxs" />
|
|
{user?.username}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : mapPreferences ? (
|
|
mapPreferences.map(({ userId, preference }) => {
|
|
return (
|
|
<div key={userId} className="stack horizontal items-center xs">
|
|
<img
|
|
src={preferenceEmojiUrl(preference)}
|
|
className="q-settings__radio__emoji"
|
|
width={18}
|
|
alt={`${preference} emoji`}
|
|
/>
|
|
{userIdToUser(userId)?.username}
|
|
</div>
|
|
);
|
|
})
|
|
) : null}
|
|
</SendouPopover>
|
|
);
|
|
}
|
|
|
|
return pickInfo(map.source);
|
|
}
|
|
|
|
function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) {
|
|
const { t } = useTranslation(["q"]);
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
const ownSide = data.groupAlpha.members.some((m) => m.id === user?.id)
|
|
? "ALPHA"
|
|
: "BRAVO";
|
|
|
|
const score = winners.reduce(
|
|
(acc, cur) => {
|
|
if (cur === "ALPHA") {
|
|
return [acc[0] + 1, acc[1]];
|
|
}
|
|
|
|
return [acc[0], acc[1] + 1];
|
|
},
|
|
[0, 0],
|
|
);
|
|
|
|
const userWon =
|
|
ownSide === "ALPHA" ? score[0] > score[1] : score[0] < score[1];
|
|
|
|
return (
|
|
<div
|
|
className={clsx("text-sm font-semi-bold", {
|
|
"text-success": userWon,
|
|
"text-warning": !userWon,
|
|
})}
|
|
>
|
|
{t("q:match.reporting", {
|
|
score: score.join("-"),
|
|
outcome: userWon ? t("q:match.outcome.win") : t("q:match.outcome.loss"),
|
|
})}
|
|
</div>
|
|
);
|
|
}
|