From a4f9b8d251d704e40ea3cf37bee8475a05f0de73 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:12:24 +0300 Subject: [PATCH] sq initial --- .../loaders/q.match.$id.server.ts | 107 +- .../routes/q.match.$id.module.css | 123 -- .../sendouq-match/routes/q.match.$id.tsx | 1564 +++-------------- app/features/sendouq/core/SendouQ.server.ts | 1 + 4 files changed, 277 insertions(+), 1518 deletions(-) delete mode 100644 app/features/sendouq-match/routes/q.match.$id.module.css diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 5482bce0c..aed6a631f 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -1,21 +1,81 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; -import { chatAccessible } from "~/features/chat/chat-utils"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; -import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server"; -import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; -import { databaseTimestampToDate } from "~/utils/dates"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { qMatchPageParamsSchema } from "../q-match-schemas"; +// export const loader = async ({ params }: LoaderFunctionArgs) => { +// const user = getUser(); +// const matchId = parseParams({ +// params, +// schema: qMatchPageParamsSchema, +// }).id; +// const matchUnmapped = notFoundIfFalsy( +// await SQMatchRepository.findById(matchId), +// ); + +// const matchUsers = [ +// ...matchUnmapped.groupAlpha.members, +// ...matchUnmapped.groupBravo.members, +// ].map((m) => m.id); +// const privateNotes = user +// ? await PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers) +// : undefined; + +// const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes); + +// const rawReportedWeapons = match.reportedAt +// ? await ReportedWeaponRepository.findByMatchId(matchId) +// : null; + +// return { +// match, +// reportedWeapons: match.reportedAt +// ? reportedWeaponsToArrayOfArrays({ +// groupAlpha: match.groupAlpha, +// groupBravo: match.groupBravo, +// mapList: match.mapList, +// reportedWeapons: rawReportedWeapons, +// }) +// : null, +// rawReportedWeapons, +// chatCode: (() => { +// const isStaff = user?.roles.includes("STAFF") ?? false; +// const isParticipant = user && matchUsers.includes(user.id); + +// if (!(isStaff || isParticipant)) return null; + +// const accessible = chatAccessible({ +// isStaff, +// expiresAfterDays: 1, +// comparedTo: databaseTimestampToDate(matchUnmapped.createdAt), +// }); +// if (!accessible) return null; + +// if (!isParticipant) return match.chatCode ?? null; + +// const codes = [ +// match.chatCode, +// match.groupAlpha.chatCode, +// match.groupBravo.chatCode, +// ].filter((c): c is string => Boolean(c)); + +// if (codes.length === 0) return null; +// if (codes.length === 1) return codes[0]; +// return codes; +// })(), +// }; +// }; + export const loader = async ({ params }: LoaderFunctionArgs) => { const user = getUser(); const matchId = parseParams({ params, schema: qMatchPageParamsSchema, }).id; + const matchUnmapped = notFoundIfFalsy( await SQMatchRepository.findById(matchId), ); @@ -24,51 +84,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ...matchUnmapped.groupAlpha.members, ...matchUnmapped.groupBravo.members, ].map((m) => m.id); + const privateNotes = user ? await PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers) : undefined; const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes); - const rawReportedWeapons = match.reportedAt - ? await ReportedWeaponRepository.findByMatchId(matchId) - : null; - return { match, - reportedWeapons: match.reportedAt - ? reportedWeaponsToArrayOfArrays({ - groupAlpha: match.groupAlpha, - groupBravo: match.groupBravo, - mapList: match.mapList, - reportedWeapons: rawReportedWeapons, - }) - : null, - rawReportedWeapons, - chatCode: (() => { - const isStaff = user?.roles.includes("STAFF") ?? false; - const isParticipant = user && matchUsers.includes(user.id); - - if (!(isStaff || isParticipant)) return null; - - const accessible = chatAccessible({ - isStaff, - expiresAfterDays: 1, - comparedTo: databaseTimestampToDate(matchUnmapped.createdAt), - }); - if (!accessible) return null; - - if (!isParticipant) return match.chatCode ?? null; - - const codes = [ - match.chatCode, - match.groupAlpha.chatCode, - match.groupBravo.chatCode, - ].filter((c): c is string => Boolean(c)); - - if (codes.length === 0) return null; - if (codes.length === 1) return codes[0]; - return codes; - })(), }; }; diff --git a/app/features/sendouq-match/routes/q.match.$id.module.css b/app/features/sendouq-match/routes/q.match.$id.module.css deleted file mode 100644 index f808aa833..000000000 --- a/app/features/sendouq-match/routes/q.match.$id.module.css +++ /dev/null @@ -1,123 +0,0 @@ -.stagePopoverButton { - background-color: transparent; - color: var(--color-text-high); - font-size: var(--font-xs); - padding: 0; - border: none; - text-decoration: underline; - text-decoration-style: dotted; - font-weight: var(--weight-body); - height: 19.8281px; - - &:focus { - outline: none; - color: var(--color-text-accent); - } -} - -.modePopoverButton { - background-color: transparent; - padding: 0; - border: none; - - &:focus { - outline: none; - } -} - -.container { - /** Push footer down to avoid it "flashing" when the score reporter animates */ - padding-bottom: 14rem; -} - -.header { - line-height: 1.2; -} - -.teamsContainer { - display: grid; - grid-template-columns: 1fr; - gap: var(--s-8); -} - -.mapListChatContainer { - display: grid; - grid-template-columns: 2fr 1fr 2fr; - place-items: center; - gap: var(--s-4); -} - -.userNameContainer { - display: flex; - gap: var(--s-2); - width: 175px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.userWeaponContainer { - flex: 1; -} - -.reportSection { - display: grid; - grid-template-columns: max-content 1fr; - row-gap: var(--s-2); - column-gap: var(--s-3); - align-items: center; - font-size: var(--font-xs); -} - -.poolPassContainer { - display: flex; - gap: var(--s-2); - flex-direction: column; - max-width: max-content; -} - -.bottomMidSection { - display: flex; - flex-direction: column; - align-self: flex-start; - top: var(--layout-sticky-top); - position: sticky; -} - -.infoHeader { - text-transform: uppercase; - color: var(--color-text-high); - font-size: var(--font-xs); - line-height: 1.1; -} - -.infoValue { - font-size: var(--font-xl); - font-weight: var(--weight-semi); - letter-spacing: 1px; -} - -.screenLegality { - & svg { - width: 24px; - } -} - -.screenLegalityButton { - width: 100%; - - &:focus-visible { - outline: none !important; - } -} - -.preferenceEmoji { - filter: grayscale(100%); - transition: all 0.2s; -} - -@container (width >= 640px) { - .teamsContainer { - grid-template-columns: 1fr 1fr; - } -} diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index e81eee9a1..c8b3e0cbf 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -1,1397 +1,255 @@ -import clsx from "clsx"; -import { Archive, RefreshCcw, Scale, Users } from "lucide-react"; -import * as React from "react"; -import { Flipped, Flipper } from "react-flip-toolkit"; +import { Scale, Vote } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { FetcherWithComponents, MetaFunction } from "react-router"; -import { - Link, - useFetcher, - useLoaderData, - useNavigate, - useSearchParams, -} from "react-router"; -import { Alert } from "~/components/Alert"; -import { Avatar } from "~/components/Avatar"; -import { Divider } from "~/components/Divider"; -import { LinkButton, SendouButton } from "~/components/elements/Button"; -import { SendouPopover } from "~/components/elements/Popover"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image"; -import { DiscordIcon } from "~/components/icons/Discord"; +import { useLoaderData } from "react-router"; +import { LinkButton } from "~/components/elements/Button"; import { Main } from "~/components/Main"; -import { Placeholder } from "~/components/Placeholder"; -import { SubmitButton } from "~/components/SubmitButton"; -import { WeaponSelect } from "~/components/WeaponSelect"; -import type { Tables } from "~/db/tables"; -import { useUser } from "~/features/auth/core/user"; -import * as Seasons from "~/features/mmr/core/Seasons"; -import { GroupCard } from "~/features/sendouq/components/GroupCard"; -import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; -import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks"; -import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog"; -import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server"; -import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; -import { useHydrated } from "~/hooks/useHydrated"; -import { useMainContentWidth } from "~/hooks/useMainContentWidth"; -import { useTimeFormat } from "~/hooks/useTimeFormat"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; -import { useHasRole } from "~/modules/permissions/hooks"; -import { databaseTimestampToDate } from "~/utils/dates"; -import { animate } from "~/utils/flip"; -import invariant from "~/utils/invariant"; -import { safeNumberParse } from "~/utils/number"; -import { metaTags, type SerializeFrom } from "~/utils/remix"; -import type { SendouRouteHandle } from "~/utils/remix.server"; -import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import type { Unpacked } from "~/utils/types"; -import { assertUnreachable } from "~/utils/types"; +import { MatchActionTab } from "~/components/match-page/MatchActionTab"; import { - navIconUrl, - preferenceEmojiUrl, - SENDOU_INK_DISCORD_URL, - SENDOUQ_PAGE, - SENDOUQ_RULES_PAGE, - sendouQMatchPage, - specialWeaponImageUrl, - teamPage, -} from "~/utils/urls"; + MatchBanner, + MatchBannerContainer, +} from "~/components/match-page/MatchBanner"; +import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow"; +import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow"; +import { MatchJoinTab } from "~/components/match-page/MatchJoinTab"; +import { MatchPage } from "~/components/match-page/MatchPage"; +import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; +import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; +import { MatchTabs } from "~/components/match-page/MatchTabs"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; +import { databaseTimestampToDate } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { SENDOUQ_RULES_PAGE } from "~/utils/urls"; import { action } from "../actions/q.match.$id.server"; -import { matchEndedAtIndex } from "../core/match"; import { loader } from "../loaders/q.match.$id.server"; -import { resolveGroupMemberOf } from "../q-match-utils"; export { action, loader }; -import styles from "./q.match.$id.module.css"; - -export const meta: MetaFunction = (args) => { - const data = args.data as SerializeFrom | null; - - if (!data) return []; - - return metaTags({ - title: `SendouQ - Match #${data.match.id}`, - description: `${new Intl.ListFormat("en-US").format( - data.match.groupAlpha.members.map((m) => m.username), - )} vs. ${new Intl.ListFormat("en-US").format( - data.match.groupBravo.members.map((m) => m.username), - )}`, - location: args.location, - }); -}; - export const handle: SendouRouteHandle = { - i18n: ["q", "tournament", "user"], - breadcrumb: () => ({ - imgPath: navIconUrl("sendouq"), - href: SENDOUQ_PAGE, - type: "IMAGE", - }), + i18n: ["q"], }; -export default function QMatchShell() { - const isHydrated = useHydrated(); - - if (!isHydrated) - return ( -
- -
- ); - - return ; -} - -function QMatchPage() { - const user = useUser(); - const isStaff = useHasRole("STAFF"); - const isHydrated = useHydrated(); - const { t } = useTranslation(["q"]); - const { formatDateTime } = useTimeFormat(); - const data = useLoaderData(); - const [showWeaponsForm, setShowWeaponsForm] = React.useState(false); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - - // biome-ignore lint/correctness/useExhaustiveDependencies: biome migration - React.useEffect(() => { - setShowWeaponsForm(false); - }, [JSON.stringify(data.reportedWeapons), data.match.id]); - - const ownMember = - data.match.groupAlpha.members.find((m) => m.id === user?.id) ?? - data.match.groupBravo.members.find((m) => m.id === user?.id); - const canReportScore = Boolean( - !data.match.isLocked && (ownMember || isStaff), - ); - - const ownGroup = data.match.groupAlpha.members.some((m) => m.id === user?.id) - ? data.match.groupAlpha - : data.match.groupBravo.members.some((m) => m.id === user?.id) - ? data.match.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 groupMemberOf = resolveGroupMemberOf({ - groupAlpha: data.match.groupAlpha, - groupBravo: data.match.groupBravo, - userId: user?.id, - }); - - const addingNoteFor = ( - groupMemberOf === "ALPHA" ? data.match.groupAlpha : data.match.groupBravo - ).members.find((m) => m.id === safeNumberParse(searchParams.get("note"))); +// xxx: translate all +export default function SendouQMatchPage() { return ( -
- navigate(sendouQMatchPage(data.match.id))} - /> -
-

{t("q:match.header", { number: data.match.id })}

-
- {isHydrated - ? formatDateTime(databaseTimestampToDate(data.match.createdAt), { - day: "numeric", - month: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : // reserve place - "0/0/0 0:00"} -
-
- {showScore ? ( - <> - - {ownGroup && ownMember && data.match.reportedAt ? ( - - ) : null} - - ) : null} - {!showWeaponsForm ? ( - <> -
- {[data.match.groupAlpha, data.match.groupBravo].map((group, i) => { - const side = i === 0 ? "ALPHA" : "BRAVO"; - const isOwnGroup = groupMemberOf === side; - - const matchHasBeenReported = Boolean(data.match.reportedByUserId); - const showAddNote = - groupMemberOf === side && matchHasBeenReported; - return ( -
-
- {i === 0 ? "Alpha" : "Bravo"} - {group.team ? ( - - {group.team.avatarUrl ? ( - - ) : null} - {group.team.name} - - ) : null} -
- -
- ); - })} -
- - - ) : null} +
+ + + + +
); } -function Score({ - reportedAt, - ownTeamReported, -}: { - reportedAt: number; - ownTeamReported: boolean; -}) { - const isHydrated = useHydrated(); - const { t } = useTranslation(["q"]); - const { formatDateTime } = useTimeFormat(); +function SendouQMatchHeader() { const data = useLoaderData(); - const reporter = - data.match.groupAlpha.members.find( - (m) => m.id === data.match.reportedByUserId, - ) ?? - data.match.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.groupAlpha.id) { - return [acc[0] + 1, acc[1]]; - } - - return [acc[0], acc[1] + 1]; - }, - [0, 0], - ); - - if (score[0] === 0 && score[1] === 0) { - return ( -
-
- {data.match.isLocked - ? t("q:match.canceled") - : t("q:match.cancelRequested")} -
- {!data.match.isLocked ? ( -
- {!ownTeamReported ? ( - - ) : ( - t("q:match.cancelPendingConfirmation") - )} -
- ) : null} -
- ); - } - - return ( -
-
{score.join(" - ")}
- {data.match.isLocked ? ( -
- {t("q:match.reportedBy", { name: reporter?.username ?? "admin" })}{" "} - {isHydrated - ? formatDateTime(databaseTimestampToDate(reportedAt), { - day: "numeric", - month: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : ""} -
- ) : ( -
- {t("q:match.spInfo")} {!ownTeamReported ? : null} -
- )} -
- ); -} - -function DisputePopover() { const { t } = useTranslation(["q"]); + const season = Seasons.currentOrPrevious( + databaseTimestampToDate(data.match.createdAt), + )?.nth; + return ( - } > - {t("q:match.dispute.button")} - + {t("q:front.nav.rules.title")} + } > -

{t("q:match.dispute.p1")}

-

{t("q:match.dispute.p2")}

-
+ {t("q:match.header", { number: data.match.id })} + ); } -function AfterMatchActions({ - ownGroupId, - role, - reportedAt, - showWeaponsForm, - setShowWeaponsForm, -}: { - ownGroupId: number; - role: Tables["GroupMember"]["role"]; - reportedAt: number; - showWeaponsForm: boolean; - setShowWeaponsForm: (show: boolean) => void; -}) { - const { t } = useTranslation(["q"]); +function SendouQMatchBanner() { const data = useLoaderData(); - const lookAgainFetcher = useFetcher(); - const wasReportedInTheLastHour = - databaseTimestampToDate(reportedAt).getTime() > Date.now() - 3600 * 1000; + const countScore = (groupId: number) => + data.match.mapList.reduce( + (acc, map) => acc + (map.winnerGroupId === groupId ? 1 : 0), + 0, + ); - const season = Seasons.current(); - const showLookAgain = role === "OWNER" && wasReportedInTheLastHour && season; + const reportedCount = data.match.mapList.filter( + (map) => map.winnerGroupId, + ).length; - const wasReportedInTheLastWeek = - databaseTimestampToDate(reportedAt).getTime() > - Date.now() - 7 * 24 * 3600 * 1000; - const showWeaponsFormButton = - wasReportedInTheLastWeek && data.match.mapList[0].winnerGroupId; + const currentMap = data.match.mapList.at(reportedCount); + invariant(currentMap); return ( -
- + + - - {showLookAgain ? ( - } - state={lookAgainFetcher.state} - _action="LOOK_AGAIN" - > - {t("q:match.actions.lookAgain")} - - ) : null} - {showWeaponsFormButton ? ( - } - onPress={() => setShowWeaponsForm(!showWeaponsForm)} - variant={showWeaponsForm ? "destructive" : undefined} - > - {showWeaponsForm - ? t("q:match.actions.stopReportingWeapons") - : t("q:match.actions.reportWeapons")} - - ) : null} - - {showWeaponsForm ? : null} -
+
+ 5 +
+ + ({ + mode: map.mode, + winner: + map.winnerGroupId === data.match.groupAlpha.id + ? "ALPHA" + : map.winnerGroupId === data.match.groupBravo.id + ? "BRAVO" + : undefined, + }))} + activeRosters={{ + alpha: data.match.groupAlpha.members, + bravo: data.match.groupBravo.members, + }} + /> + ); } -function ReportWeaponsForm() { - const { t } = useTranslation(["q", "user"]); - const user = useUser(); - const data = useLoaderData(); - 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.groupAlpha.id ? "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 groupMemberOf = resolveGroupMemberOf({ - groupAlpha: data.match.groupAlpha, - groupBravo: data.match.groupBravo, - userId: user?.id, - }); - - const playersToReport = () => { - const allPlayers = [ - ...data.match.groupAlpha.members, - ...data.match.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 groupMemberOf === "ALPHA" - ? data.match.groupAlpha.members - : data.match.groupBravo.members; - } - default: - assertUnreachable(reportingMode); - } - }; - +function SendouQMatchTabs() { return ( - - + -
-

{t("q:match.report.whoToReport")}

- - - -
-
- {playedMaps.map((map, i) => { - const groupMatchMapId = map.id; - - return ( -
- - {i !== 0 && reportingMode !== "MYSELF" ? ( - - {t("q:match.report.copyWeapons")} - - ) : null} -
- {playersToReport().map((member, j) => { - const weaponSplId = - weaponsUsage.find( - (w) => - w.groupMatchMapId === groupMatchMapId && - w.userId === member.id, - )?.weaponSplId ?? null; - - return ( - - {j === 0 && reportingMode === "ALL" ? ( - - {t("q:match.sides.alpha")} - - ) : null} - {j === FULL_GROUP_SIZE && reportingMode === "ALL" ? ( - - {t("q:match.sides.bravo")} - - ) : null} -
-
- {" "} - {member.inGameName ? ( - <> - - {t("user:ign.short")}: - {" "} - {inGameNameWithoutDiscriminator( - member.inGameName, - )} - - ) : ( - member.username - )} -
-
- { - setWeaponsUsage((val) => { - const result = val.filter( - (reportedWeapon) => - reportedWeapon.groupMatchMapId !== - groupMatchMapId || - reportedWeapon.userId !== member.id, - ); - - result.push({ - weaponSplId, - mapIndex: i, - groupMatchMapId, - userId: member.id, - }); - - addRecentlyReportedWeapon(weaponSplId); - - return result; - }); - }} - /> -
-
-
- ); - })} -
-
- ); - })} -
- {weaponsUsage.flat().some((val) => val === null) ? ( -
- {t("q:match.report.error")} -
- ) : ( -
- - {t("q:match.report.submit")} - -
- )} -
- ); -} - -function BottomSection({ - canReportScore, - ownTeamReported, - participatingInTheMatch, -}: { - canReportScore: boolean; - ownTeamReported: boolean; - participatingInTheMatch: boolean; -}) { - const { t } = useTranslation(["q", "common"]); - const width = useMainContentWidth(); - const isMobile = width < 650; - const isHydrated = useHydrated(); - const user = useUser(); - const isStaff = useHasRole("STAFF"); - const data = useLoaderData(); - const submitScoreFetcher = useFetcher(); - const cancelFetcher = useFetcher(); - - const showMid = !data.match.isLocked && (participatingInTheMatch || isStaff); - - const poolCode = () => { - const stringId = String(data.match.id); - const lastDigit = stringId[stringId.length - 1]; - - return `SQ${lastDigit}`; - }; - - if (!isHydrated) return null; - - const mapListElement = ( - - ); - - const roomJoiningInfoElement = ( -
- - -
- ); - - const rulesButtonElement = ( - } - > - {t("q:front.nav.rules.title")} - - ); - - const helpdeskButtonElement = ( - } - > - {t("q:match.helpdesk")} - - ); - - const groupMemberOf = resolveGroupMemberOf({ - groupAlpha: data.match.groupAlpha, - groupBravo: data.match.groupBravo, - userId: user?.id, - }); - - const cancelMatchElement = - canReportScore && !data.match.isLocked ? ( - { + logger.info("onSubbedOutChange", { teamId, subbedOut }); + }} + teams={[ + { + team: { + id: 1, + name: "me in japan", + url: "/t/me-in-japan", + }, + members: [ + { + id: 1, + username: "Sendou", + discordId: "123", + discordAvatar: null, + customUrl: "sendou", + }, + { + id: 2, + username: "Lean", + discordId: "456", + discordAvatar: null, + customUrl: null, + }, + { + id: 3, + username: "Kiver", + discordId: "789", + discordAvatar: null, + customUrl: null, + }, + { + id: 4, + username: "Brian", + discordId: "012", + discordAvatar: null, + customUrl: null, + }, + { + id: 9, + username: "Poppy", + discordId: "567", + discordAvatar: null, + customUrl: null, + }, + ], + subbedOut: [9], + }, + { + team: { + id: 2, + name: "Question Mark", + url: "/t/question-mark", + }, + members: [ + { + id: 5, + username: "Naga", + discordId: "345", + discordAvatar: null, + customUrl: null, + }, + { + id: 6, + username: "Grey", + discordId: "678", + discordAvatar: null, + customUrl: null, + }, + { + id: 7, + username: "Zack", + discordId: "901", + discordAvatar: null, + customUrl: null, + }, + { + id: 8, + username: "Lime", + discordId: "234", + discordAvatar: null, + customUrl: null, + }, + ], + }, ]} - submitButtonText={t("common:actions.cancel")} - fetcher={cancelFetcher} - > - - {t("q:match.cancelMatch")} - - - ) : null; - - const screenBanned = Boolean( - data.match.groupAlpha.noScreen || data.match.groupBravo.noScreen, - ); - - const screenLegalityInfoElement = !data.match.isLocked ? ( - - ) : null; - - if (!showMid) { - return mapListElement; - } - - if (isMobile) { - return ( -
-
- {roomJoiningInfoElement} -
- {screenLegalityInfoElement} - {rulesButtonElement} - {helpdeskButtonElement} - {cancelMatchElement} -
-
- {mapListElement} -
- ); - } - - return ( - <> -
- {mapListElement} -
-
- {roomJoiningInfoElement} - {rulesButtonElement} - {helpdeskButtonElement} - {screenLegalityInfoElement} - {cancelMatchElement} -
-
-
- {cancelFetcher.data?.error === "cant-cancel" ? ( -
- {t("q:match.errors.cantCancel")} -
- ) : null} - {submitScoreFetcher.data?.error === "different" ? ( -
- {t("q:match.errors.different")} -
- ) : null} - - ); -} - -function ScreenLegalityInfo({ ban }: { ban: boolean }) { - const { t } = useTranslation(["q", "weapons"]); - - return ( -
- - -
- {t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)} -
-
- - } - > - {ban - ? t("q:match.screen.ban", { - special: t("weapons:SPECIAL_19"), - }) - : t("q:match.screen.allowed", { - special: t("weapons:SPECIAL_19"), - })} -
-
- ); -} - -function InfoWithHeader({ header, value }: { header: string; value: string }) { - return ( -
-
{header}
-
{value}
-
- ); -} - -function MapList({ - canReportScore, - isResubmission, - fetcher, -}: { - canReportScore: boolean; - isResubmission: boolean; - fetcher: FetcherWithComponents; -}) { - const { t } = useTranslation(["q"]); - const user = useUser(); - const isStaff = useHasRole("STAFF"); - const data = useLoaderData(); - 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.match.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 ( - - - - -
- {data.match.mapList.map((map, i) => { - return ( - { - if (!newReportedWeapon) return; - setOwnWeaponsUsage((val) => { - const result = val.filter( - (reportedWeapon) => - reportedWeapon.groupMatchMapId !== - newReportedWeapon.groupMatchMapId, - ); - - if (typeof newReportedWeapon.weaponSplId === "number") { - result.push(newReportedWeapon); - } - - return result; - }); - }} - /> - ); - })} -
-
- {scoreCanBeReported && isStaff ? ( -
- - Report as admin -
- ) : null} - {scoreCanBeReported ? ( -
- - - {isResubmission - ? t("q:match.submitScores.adjusted") - : t("q:match.submitScores")} - -
- ) : null} -
- ); -} - -function MapListMap({ - i, - map, - winners, - setWinners, - canReportScore, - weapons, - onOwnWeaponSelected, - showReportedOwnWeapon, - recentlyReportedWeapons, - addRecentlyReportedWeapon, -}: { - i: number; - map: Unpacked["match"]["mapList"]>; - winners: ("ALPHA" | "BRAVO")[]; - setWinners?: (winners: ("ALPHA" | "BRAVO")[]) => void; - canReportScore: boolean; - weapons?: (MainWeaponId | null)[] | null; - onOwnWeaponSelected?: (weapon: ReportedWeaponForMerging | null) => void; - showReportedOwnWeapon: boolean; - recentlyReportedWeapons?: MainWeaponId[]; - addRecentlyReportedWeapon?: (weapon: MainWeaponId) => void; -}) { - const user = useUser(); - const data = useLoaderData(); - 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 ( - <> - • {t("q:match.results.unplayed")} - - ); - - const winnerSide = - winnerId === data.match.groupAlpha.id - ? t("q:match.sides.alpha") - : t("q:match.sides.bravo"); - - return <>• {t("q:match.won", { side: winnerSide })}; - }; - - const groupMemberOf = resolveGroupMemberOf({ - groupAlpha: data.match.groupAlpha, - groupBravo: data.match.groupBravo, - userId: user?.id, - }); - - const relativeSideText = (side: "ALPHA" | "BRAVO") => { - if (!groupMemberOf) return ""; - - return groupMemberOf === side ? " (us)" : " (them)"; - }; - - const modePreferences = data.match.memento?.modePreferences?.[map.mode]; - - const userIdToName = (userId: number) => { - const member = [ - ...data.match.groupAlpha.members, - ...data.match.groupBravo.members, - ].find((m) => m.id === userId); - - return member?.username ?? ""; - }; - - return ( -
- -
- -
-
- {i + 1}){" "} - {modePreferences ? ( - - - - } - > -
- {t(`game-misc:MODE_LONG_${map.mode}`)} -
- {modePreferences.map(({ userId, preference }) => { - return ( -
- {`${preference} - {userIdToName(userId)} -
- ); - })} -
- ) : ( - - )}{" "} - {t(`game-misc:STAGE_${map.stageId}`)} -
-
- {" "} - {winningInfoText(map.winnerGroupId)} -
-
-
-
- {weapons && map.winnerGroupId && !showReportedOwnWeapon ? ( -
- {weapons.map((weaponSplId, i) => { - return ( - - {typeof weaponSplId === "number" ? ( - - ) : ( -
- ? -
- )} - {i === 3 ?
: null} - - ); - })} -
- ) : null} - {showWinnerReportRow(i) ? ( - { - await animate(el, [{ opacity: 0 }, { opacity: 1 }], { - duration: 300, - }); - el.style.opacity = "1"; - }} - > -
- -
-
- - -
-
- - -
-
- - {showReportedOwnWeapon && onOwnWeaponSelected ? ( - <> - - { - const userId = user!.id; - const groupMatchMapId = map.id; - - if (typeof weaponSplId === "number") { - addRecentlyReportedWeapon?.(weaponSplId); - } - - onOwnWeaponSelected( - typeof weaponSplId === "number" - ? { - weaponSplId, - mapIndex: i, - groupMatchMapId, - userId, - } - : null, - ); - }} - /> - - ) : null} -
-
- ) : null} -
- ); -} - -function MapListMapPickInfo({ - i, - map, -}: { - i: number; - map: Unpacked["match"]["mapList"]>; -}) { - const data = useLoaderData(); - const { t } = useTranslation(["q", "game-misc", "tournament"]); - - const pickInfo = (source: string) => { - if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker"); - if (source === "DEFAULT") return t("tournament:pickInfo.default"); - - const poolMemberIds = sourcePoolMemberIds(); - const playerCount = - poolMemberIds.length > 0 - ? poolMemberIds.length - : (mapPreferences?.length ?? 0); - - return ( -
- - - {t("tournament:pickInfo.votes", { - count: playerCount, - })} - -
- ); - }; - - const userIdToUser = (userId: number) => { - const member = [ - ...data.match.groupAlpha.members, - ...data.match.groupBravo.members, - ].find((m) => m.id === userId); - - return member; - }; - - const sourcePoolMemberIds = () => { - const result: number[] = []; - - if (!data.match.memento?.pools) return result; - - const pickerGroups = [data.match.groupAlpha, data.match.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; - - if (map.source === "DEFAULT") return true; - - return sourcePoolMemberIds().length > 0; - }; - - if (showPopover()) { - return ( - - {pickInfo(map.source)} - - } - > -
- {t(`game-misc:MODE_SHORT_${map.mode}`)}{" "} - {t(`game-misc:STAGE_${map.stageId}`)} -
- {map.source === "DEFAULT" ? ( -
- {t("tournament:pickInfo.default.explanation")} -
- ) : sourcePoolMemberIds().length > 0 ? ( -
- {sourcePoolMemberIds().map((userId) => { - const user = userIdToUser(userId); - return ( -
- - {user?.username} -
- ); - })} -
- ) : mapPreferences ? ( - mapPreferences.map(({ userId, preference }) => { - return ( -
- {`${preference} - {userIdToUser(userId)?.username} -
- ); - }) - ) : null} -
- ); - } - - return pickInfo(map.source); -} - -function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) { - const { t } = useTranslation(["q"]); - const user = useUser(); - const data = useLoaderData(); - - const ownSide = data.match.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 ( -
- {t("q:match.reporting", { - score: score.join("-"), - outcome: userWon ? t("q:match.outcome.win") : t("q:match.outcome.loss"), - })} -
+ + ); } diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index ea65cd108..905110aa1 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -155,6 +155,7 @@ class SendouQClass { return this.groups.find((group) => group.inviteCode === inviteCode); } + // xxx: only needed stuff here /** * Maps a database match to a format with appropriate censoring based on user permissions. * Includes private notes for team members and censors sensitive data for non-participants.